diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 586ded1a5c..85ed2fdfb3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,6 +86,7 @@ dependencies { implementation(projects.features.swap.api) implementation(projects.features.swap.presentation) implementation(projects.features.swap.domain) + implementation(projects.features.swap.domain.api) implementation(projects.features.swap.data) implementation(projects.features.tester.api) implementation(projects.features.tester.impl) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 6ef8ce45d1..4a1be08512 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 6ef8ce45d183905b5752e2d33c1d8bf2f4bcace6 +Subproject commit 4a1be08512fe2b45504f2bdaa0e35f5c11e11319 diff --git a/features/onboarding/src/main/java/com/tangem/feature/onboarding/data/di/OnboardingDataModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MnemonicModule.kt similarity index 77% rename from features/onboarding/src/main/java/com/tangem/feature/onboarding/data/di/OnboardingDataModule.kt rename to app/src/main/java/com/tangem/tap/di/domain/MnemonicModule.kt index a853bf6c77..8f62a7e255 100644 --- a/features/onboarding/src/main/java/com/tangem/feature/onboarding/data/di/OnboardingDataModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MnemonicModule.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.onboarding.data.di +package com.tangem.tap.di.domain import android.content.Context import com.tangem.crypto.bip39.Wordlist @@ -14,11 +14,11 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -class OnboardingDataModule { +class MnemonicModule { @Provides @Singleton - fun provideSeedPhraseSdkRepository(@ApplicationContext context: Context): MnemonicRepository { + fun provideMnemonicRepository(@ApplicationContext context: Context): MnemonicRepository { return DefaultMnemonicRepository(Wordlist.getWordlist(context)) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt index 1c596a4b4d..7334227d99 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt @@ -8,6 +8,7 @@ import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.settings.* import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.settings.repositories.SwapPromoRepository import com.tangem.tap.domain.TangemSdkManager import com.tangem.tap.domain.settings.DefaultLegacySettingsRepository import dagger.Module @@ -103,4 +104,20 @@ internal object SettingsDomainModule { fun provideIsWalletsScrollPreviewEnabled(settingsRepository: SettingsRepository): IsWalletsScrollPreviewEnabled { return IsWalletsScrollPreviewEnabled(settingsRepository = settingsRepository) } + + @Provides + @ViewModelScoped + fun provideShouldShowSwapPromoWalletUseCase( + swapPromoRepository: SwapPromoRepository, + ): ShouldShowSwapPromoWalletUseCase { + return ShouldShowSwapPromoWalletUseCase(swapPromoRepository) + } + + @Provides + @ViewModelScoped + fun provideShouldShowSwapPromoTokenUseCase( + swapPromoRepository: SwapPromoRepository, + ): ShouldShowSwapPromoTokenUseCase { + return ShouldShowSwapPromoTokenUseCase(swapPromoRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 0220fa2823..b07f25f84d 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -1,9 +1,11 @@ package com.tangem.tap.di.domain import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.settings.ShouldShowSwapPromoTokenUseCase import com.tangem.domain.tokens.* import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -95,6 +97,9 @@ internal object TokensDomainModule { currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, networksRepository: NetworksRepository, + marketCryptoCurrencyRepository: MarketCryptoCurrencyRepository, + swapRepository: SwapRepository, + showSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase, dispatchers: CoroutineDispatcherProvider, ): GetCurrencyWarningsUseCase { return GetCurrencyWarningsUseCase( @@ -102,6 +107,9 @@ internal object TokensDomainModule { currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, networksRepository = networksRepository, + marketCryptoCurrencyRepository = marketCryptoCurrencyRepository, + swapRepository = swapRepository, + showSwapPromoTokenUseCase = showSwapPromoTokenUseCase, dispatchers = dispatchers, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletManagersFacadeModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletManagersFacadeModule.kt index 63afaaeaf2..02a52ce342 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletManagersFacadeModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletManagersFacadeModule.kt @@ -8,6 +8,7 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.walletmanager.DefaultWalletManagersFacade import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.feature.onboarding.data.MnemonicRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,6 +25,7 @@ internal object WalletManagersFacadeModule { walletManagersStore: WalletManagersStore, userWalletsStore: UserWalletsStore, configManager: ConfigManager, + mnemonicRepository: MnemonicRepository, assetReader: AssetReader, @SdkMoshi moshi: Moshi, ): WalletManagersFacade { @@ -33,6 +35,7 @@ internal object WalletManagersFacadeModule { configManager = configManager, assetReader = assetReader, moshi = moshi, + mnemonic = mnemonicRepository.generateDefaultMnemonic(), ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt index a4b9510d38..2db93bfead 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt @@ -4,7 +4,7 @@ import com.tangem.Message import com.tangem.blockchain.blockchains.ethereum.EthereumGasLoader import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumUtils -import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.Companion.toKeccak +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak import com.tangem.blockchain.common.* import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.Result diff --git a/app/src/main/java/com/tangem/tap/features/demo/DemoTransactionSender.kt b/app/src/main/java/com/tangem/tap/features/demo/DemoTransactionSender.kt index 7e24b61c6d..faecf5aac1 100644 --- a/app/src/main/java/com/tangem/tap/features/demo/DemoTransactionSender.kt +++ b/app/src/main/java/com/tangem/tap/features/demo/DemoTransactionSender.kt @@ -21,6 +21,10 @@ class DemoTransactionSender(private val walletManager: WalletManager) : Transact ) } + override suspend fun estimateFee(amount: Amount, destination: String): Result { + return getFee(amount, walletManager.wallet.address) + } + override suspend fun send(transactionData: TransactionData, signer: TransactionSigner): SimpleResult { val signerResponse = signer.sign( hash = getDataToSign(), diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt index 70d580f3c7..6cd26d788e 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultExpressAuthProvider.kt @@ -15,7 +15,7 @@ internal class DefaultExpressAuthProvider( private var uuid = AtomicReference(UUID.randomUUID()) override fun getApiKey(): String { - return configManager.config.tangemExpressApiKey + return configManager.config.express?.apiKey ?: "" } override fun getUserId(): String { diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultOneInchProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultOneInchProvider.kt deleted file mode 100644 index ade4f48a0f..0000000000 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultOneInchProvider.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.network.auth - -import com.tangem.datasource.config.ConfigManager -import com.tangem.lib.auth.AuthBearerProvider - -internal class DefaultOneInchProvider( - private val configManager: ConfigManager, -) : AuthBearerProvider { - - override fun getApiKey(): String { - return configManager.config.oneInchApiKey - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt index d59b3f4792..fa9aa920a1 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt @@ -2,12 +2,10 @@ package com.tangem.tap.network.auth.di import com.tangem.datasource.config.ConfigManager import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.lib.auth.AuthBearerProvider import com.tangem.lib.auth.AuthProvider import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.tap.network.auth.DefaultAuthProvider import com.tangem.tap.network.auth.DefaultExpressAuthProvider -import com.tangem.tap.network.auth.DefaultOneInchProvider import com.tangem.tap.proxy.AppStateHolder import dagger.Module import dagger.Provides @@ -36,12 +34,4 @@ class AuthModule { configManager = configManager, ) } - - @Provides - @Singleton - fun provideOneInchAuthProvider(configManager: ConfigManager): AuthBearerProvider { - return DefaultOneInchProvider( - configManager = configManager, - ) - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt b/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt index e6527b0944..022b56497b 100644 --- a/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt +++ b/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt @@ -11,6 +11,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.domain.wallets.models.UserWallet import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.extensions.dispatchWithMain import com.tangem.tap.common.extensions.onUserWalletSelected import com.tangem.tap.common.redux.AppState import com.tangem.tap.domain.TangemSdkManager @@ -63,6 +64,10 @@ class AppStateHolder @Inject constructor() : WalletsStateHolder, ReduxNavControl mainStore?.dispatch(action) } + override suspend fun dispatchWithMain(action: Action) { + mainStore?.dispatchWithMain(action) + } + override suspend fun onUserWalletSelected(userWallet: UserWallet) { mainStore?.onUserWalletSelected(userWallet) } diff --git a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt index ac8e2be9ee..a7899eace5 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -1,10 +1,17 @@ package com.tangem.tap.proxy +import androidx.core.text.isDigitsOnly import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.Message +import com.tangem.blockchain.blockchains.binance.BinanceTransactionExtras +import com.tangem.blockchain.blockchains.cosmos.CosmosTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.blockchains.optimism.OptimismWalletManager +import com.tangem.blockchain.blockchains.stellar.StellarMemo +import com.tangem.blockchain.blockchains.stellar.StellarTransactionExtras +import com.tangem.blockchain.blockchains.ton.TonTransactionExtras +import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder import com.tangem.blockchain.common.* import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee @@ -140,6 +147,26 @@ class TransactionManagerImpl( return blockchain.getExploreTxUrl(txAddress) } + override fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? { + val blockchain = Blockchain.fromNetworkId(networkId) + if (memo == null) return null + return when (blockchain) { + Blockchain.Stellar -> { + val xlmMemo = if (memo.isNotEmpty() && memo.isDigitsOnly()) { + StellarMemo.Id(memo.toBigInteger()) + } else { + StellarMemo.Text(memo) + } + StellarTransactionExtras(xlmMemo) + } + Blockchain.Binance -> BinanceTransactionExtras(memo) + Blockchain.XRP -> memo.toLongOrNull()?.let { XrpTransactionBuilder.XrpTransactionExtras(it) } + Blockchain.Cosmos -> CosmosTransactionExtras(memo) + Blockchain.TON -> TonTransactionExtras(memo) + else -> null + } + } + override fun getNativeTokenDecimals(networkId: String): Int { return Blockchain.fromNetworkId(networkId)?.decimals() ?: error("blockchain not found") } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt index b78d485fa6..61c2a6c25a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt @@ -49,6 +49,7 @@ interface TangemExpressApi { @Query("providerId") providerId: String, @Query("rateType") rateType: String, @Query("toAddress") toAddress: String, + @Query("requestId") requestId: String, ): ApiResponse @GET("exchange-status") diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt index 72deee1a57..7496a0a8e2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt @@ -3,6 +3,10 @@ package com.tangem.datasource.api.express.models.response import com.squareup.moshi.Json import java.math.BigDecimal +data class ExchangeDataResponseWithTxDetails( + val dataResponse: ExchangeDataResponse, + val txDetails: TxDetails, +) data class ExchangeDataResponse( @Json(name = "fromAmount") val fromAmount: String, @@ -16,12 +20,26 @@ data class ExchangeDataResponse( @Json(name = "toDecimals") val toDecimals: Int, - @Json(name = "txType") - val txType: TxType, - @Json(name = "txId") val txId: String, // inner tangem-express transaction id + @Json(name = "txDetailsJson") + val txDetailsJson: String, + + @Json(name = "signature") + val signature: String, +) + +data class TxDetails( + @Json(name = "payoutAddress") + val payoutAddress: String, + + @Json(name = "requestId") + val requestId: String, + + @Json(name = "txType") + val txType: TxType, + @Json(name = "txFrom") val txFrom: String?, // account for debiting tokens (same as toAddress) if DEX, null if CEX @@ -39,6 +57,12 @@ data class ExchangeDataResponse( @Json(name = "externalTxUrl") val externalTxUrl: String?, // null if DEX, url of provider exchange status page if CEX + + @Json(name = "txExtraIdName") + val txExtraIdName: String?, + + @Json(name = "txExtraId") + val txExtraId: String?, ) enum class TxType { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt index 4e2bc6d69c..acbd56ec03 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt @@ -17,6 +17,12 @@ data class ExchangeProvider( @Json(name = "imageSmall") val imageSmallUrl: String, + + @Json(name = "termsOfUse") + val termsOfUse: String?, + + @Json(name = "privacyPolicy") + val privacyPolicy: String?, ) enum class ExchangeProviderType { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeStatusResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeStatusResponse.kt index 94cd9cbf1f..6af6ee2409 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeStatusResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeStatusResponse.kt @@ -48,6 +48,9 @@ enum class ExchangeStatus { @Json(name = "verifying") VERIFYING, + + @Json(name = "expired") + CANCELLED, } data class ExchangeStatusError( diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/OneInchApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/OneInchApi.kt deleted file mode 100644 index cf950a0f66..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/OneInchApi.kt +++ /dev/null @@ -1,205 +0,0 @@ -package com.tangem.datasource.api.oneinch - -import com.tangem.datasource.api.oneinch.models.AllowanceResponse -import com.tangem.datasource.api.oneinch.models.ApproveCalldataResponse -import com.tangem.datasource.api.oneinch.models.ApproveSpenderResponse -import com.tangem.datasource.api.oneinch.models.ProtocolsResponse -import com.tangem.datasource.api.oneinch.models.QuoteResponse -import com.tangem.datasource.api.oneinch.models.StatusResponse -import com.tangem.datasource.api.oneinch.models.SwapResponse -import com.tangem.datasource.api.oneinch.models.TokensResponse -import retrofit2.Response -import retrofit2.http.GET -import retrofit2.http.Query - -interface OneInchApi { - - /** - * Healthcheck return 200 if service is available - * - * @return [StatusResponse] - */ - @GET("healthcheck") - suspend fun healthcheck(): StatusResponse - - //region Approve - /** - * Address of the 1inch router that must be trusted to spend funds for the exchange - * - * @return [ApproveSpenderResponse] - */ - @GET("approve/spender") - suspend fun approveSpender(): ApproveSpenderResponse - - /** - * Generate data for calling the contract in order to allow the 1inch router to spend funds - * - * @param tokenAddress Token address you want to exchange - * @param amount The number of tokens that the 1inch router is allowed to spend. - * If not specified, it will be allowed to spend an infinite amount of tokens. - * - * @return [ApproveCalldataResponse] Transaction body to allow the exchange with the 1inch router - */ - @GET("approve/transaction") - suspend fun approveTransaction( - @Query("tokenAddress") tokenAddress: String, - @Query("amount") amount: String? = null, - ): ApproveCalldataResponse - - /** - * Get the number of tokens that the 1inch router is allowed to spend - * - * @param tokenAddress Token address you want to exchange - * @param walletAddress Wallet address for which you want to check - * - * @return [AllowanceResponse] - */ - @GET("approve/allowance") - suspend fun approveAllowance( - @Query("tokenAddress") tokenAddress: String, - @Query("walletAddress") walletAddress: String, - ): Response - //endregion Approve - - //region Info - /** - * List of tokens that are available for swap in the 1inch Aggregation protocol - * - * @return [TokensResponse] - */ - @GET("tokens") - suspend fun tokensAvailable(): TokensResponse - - /** - * List of liquidity sources that are available for routing in the 1inch Aggregation protocol - * - * @return - */ - @GET("liquidity-sources") - suspend fun liquiditySources(): ProtocolsResponse - //endregion Info - - //region Swap - /** - * Find the best quote to exchange via 1inch router - * - * @param fromTokenAddress Example : 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE - * @param toTokenAddress Example : 0x111111111117dc0aa78b770fa6a738034120c302 - * @param amount amount of a token to sell, set in minimal divisible units e.g.: - * 1.00 DAI set as 1000000000000000000 - * 51.03 USDC set as 51030000 - * - * @param protocols default: all - * @param fee this percentage of fromTokenAddress token amount will be sent to referrerAddress, - * the rest will be used as input for a swap - * Min: 0; max: 3; Max: 0; max: 3; default: 0; !should be the same for quote and swap! - * - * @param gasLimit maximum amount of gas for a swap; - * @param connectorTokens token-connectors can be specified via this parameter. - * The more is set — the longer route estimation will take. - * If not set, default token-connectors will be usedmax: 5; !should be the same for quote and swap! - * - * @param complexityLevel maximum number of token-connectors to be used in a transaction. - * The more is used — the longer route estimation will take - * min: 0; max: 3; default: 2; !should be the same for quote and swap! - * - * @param mainRouteParts default: 10; max: 50 !should be the same for quote and swap! - * @param parts limit maximum number of parts each main route parts can be split into; - * should be the same for a quote and swap - * default: 20; max: 100 - * - * @param gasPrice 1inch takes in account gas expenses to determine exchange route. - * It is important to use the same gas price on the quote and swap methods. - * Gas price set in wei: 12.5 GWEI set as 12500000000 - * default: fast from network - * - * @return [QuoteResponse] - */ - @GET("quote") - suspend fun quote( - @Query("src") fromTokenAddress: String, - @Query("dst") toTokenAddress: String, - @Query("amount") amount: String, - @Query("protocols") protocols: String? = null, - @Query("fee") fee: String? = null, - @Query("gasLimit") gasLimit: String? = null, - @Query("connectorTokens") connectorTokens: String? = null, - @Query("complexityLevel") complexityLevel: String? = null, - @Query("mainRouteParts") mainRouteParts: String? = null, - @Query("parts") parts: String? = null, - @Query("gasPrice") gasPrice: String? = null, - @Query("includeTokensInfo") includeTokensInfo: Boolean = true, - ): Response - - /** - * Generate data for calling the 1inch router for exchange - * - * @param fromTokenAddress Example : 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE - * @param toTokenAddress Example : 0x111111111117dc0aa78b770fa6a738034120c302 - * @param amount amount of a token to sell, set in minimal divisible units e.g.: - * 1.00 DAI set as 1000000000000000000 - * 51.03 USDC set as 51030000 - * - * @param fromAddress The address that calls the 1inch contract - * @param slippage limit of price slippage you are willing to accept in percentage, may be set with decimals. - * &slippage=0.5 means 0.5% slippage is acceptable. Low values increase chances that transaction will fail, - * high values increase chances of front running. min: 0; max: 50; - * - * @param protocols default: all - * @param destinationAddress Receiver of destination currency. default: fromAddress - * @param fee this percentage of fromTokenAddress token amount will be sent to referrerAddress, - * the rest will be used as input for a swap - * Min: 0; max: 3; Max: 0; max: 3; default: 0; !should be the same for quote and swap! - * - * @param permit https://eips.ethereum.org/EIPS/eip-2612 - * @param compatibilityMode Allows to build calldata without optimized routers - * @param burnChi If true, CHI will be burned from fromAddress to compensate gas. - * Check CHI balance and allowance before turning that on. CHI should be approved for the spender address - * - * @param connectorTokens token-connectors can be specified via this parameter. - * The more is set — the longer route estimation will take. - * If not set, default token-connectors will be usedmax: 5; !should be the same for quote and swap! - * - * @param complexityLevel maximum number of token-connectors to be used in a transaction. - * The more is used — the longer route estimation will take - * min: 0; max: 3; default: 2; !should be the same for quote and swap! - * - * @param mainRouteParts default: 10; max: 50 !should be the same for quote and swap! - * @param parts limit maximum number of parts each main route parts can be split into; - * should be the same for a quote and swap - * default: 20; max: 100 - * - * @param gasLimit maximum amount of gas for a swap; - * @param gasPrice 1inch takes in account gas expenses to determine exchange route. - * It is important to use the same gas price on the quote and swap methods. - * Gas price set in wei: 12.5 GWEI set as 12500000000 - * default: fast from network - * - * @return [SwapResponse] - */ - @GET("swap") - suspend fun swap( - @Query("src") fromTokenAddress: String, - @Query("dst") toTokenAddress: String, - @Query("amount") amount: String, - @Query("from") fromAddress: String, - @Query("slippage") slippage: Int, - @Query("protocols") protocols: String? = null, - @Query("receiver") destinationAddress: String? = null, - @Query("referrer") referrerAddress: String? = null, - @Query("fee") fee: String? = null, - @Query("disableEstimate") disableEstimate: Boolean? = null, - @Query("permit") permit: String? = null, - @Query("compatibility") compatibilityMode: Boolean? = null, - @Query("burnChi") burnChi: Boolean? = null, - @Query("allowPartialFill") allowPartialFill: Boolean? = null, - @Query("parts") parts: String? = null, - @Query("mainRouteParts") mainRouteParts: String? = null, - @Query("connectorTokens") connectorTokens: String? = null, - @Query("complexityLevel") complexityLevel: String? = null, - @Query("gasLimit") gasLimit: String? = null, - @Query("gasPrice") gasPrice: String? = null, - @Query("includeTokensInfo") includeTokensInfo: Boolean = true, - ): Response - //endregion Swap -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/OneInchApiFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/OneInchApiFactory.kt deleted file mode 100644 index da21f86558..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/OneInchApiFactory.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.datasource.api.oneinch - -class OneInchApiFactory { - - private val oneInchApiMap = mutableMapOf() - - fun putApi(networkId: String, api: OneInchApi) { - oneInchApiMap[networkId] = api - } - - fun getApi(networkId: String): OneInchApi { - return oneInchApiMap[networkId] ?: error("no api found for networkId $networkId") - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/errors/OneIncResponseException.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/errors/OneIncResponseException.kt deleted file mode 100644 index 94c94f6e32..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/errors/OneIncResponseException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.datasource.api.oneinch.errors - -import com.tangem.datasource.api.oneinch.models.SwapErrorDto - -class OneIncResponseException(val data: SwapErrorDto) : Exception() \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/AllowanceResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/AllowanceResponse.kt deleted file mode 100644 index 16956f907e..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/AllowanceResponse.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -data class AllowanceResponse( - @Json(name = "allowance") val allowance: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ApproveCalldataResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ApproveCalldataResponse.kt deleted file mode 100644 index f57fab94fd..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ApproveCalldataResponse.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -/** - * Approve calldata response - * - * @property data The encoded data to call the approve method on the swapped token contract - * @property gasPrice Gas price for fast transaction processing - * @property toAddress Token address that will be allowed to exchange through 1inch router - * @property value Native token value in WEI (for approve is always 0) - */ -data class ApproveCalldataResponse( - @Json(name = "data") val data: String, - @Json(name = "gasPrice") val gasPrice: String, - @Json(name = "to") val toAddress: String, - @Json(name = "value") val value: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ApproveSpenderResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ApproveSpenderResponse.kt deleted file mode 100644 index 0ee9d5b2ac..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ApproveSpenderResponse.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -/** - * Approve spender response - * - * @property address Address of the 1inch router that must be trusted to spend funds for the exchange - */ -data class ApproveSpenderResponse( - @Json(name = "address") val address: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ProtocolsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ProtocolsResponse.kt deleted file mode 100644 index b083cd1ce8..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/ProtocolsResponse.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -/** - * Protocols response - * - * @property protocols List of protocols that are available for routing in the 1inch Aggregation protocol - */ -data class ProtocolsResponse( - @Json(name = "protocols") val protocols: List, -) - -/** - * Protocol image - * - * @property id Protocol id - * @property title Protocol title - * @property image Protocol logo image - * @property imageColor Protocol logo image in color - */ -data class ProtocolImageDto( - @Json(name = "id") val id: String, - @Json(name = "title") val title: String, - @Json(name = "img") val image: String, - @Json(name = "img_color") val imageColor: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/QuoteResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/QuoteResponse.kt deleted file mode 100644 index 692f036f24..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/QuoteResponse.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -/** - * Quote response - * - * @property toToken Destination token info - * @property toTokenAmount Expected amount of destination token - */ -data class QuoteResponse( - @Json(name = "toToken") val toToken: TokenOneInchDto, - @Json(name = "toAmount") val toTokenAmount: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/StatusResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/StatusResponse.kt deleted file mode 100644 index 576851a3b5..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/StatusResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -data class StatusResponse( - @Json(name = "status") val status: String, - @Json(name = "provider") val provider: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/SwapErrorDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/SwapErrorDto.kt deleted file mode 100644 index fe342de231..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/SwapErrorDto.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -/** - * Swap error dto - * - * One of the following errors: - * - * -Insufficient liquidity - * -Cannot estimate - * -You may not have enough ETH balance for gas fee - * -FromTokenAddress cannot be equals to toTokenAddress - * -Cannot estimate. Don't forget about miner fee. Try to leave the buffer of ETH for gas - * -Not enough balance - * -Not enough allowance - * - * @property statusCode HTTP code - * @property error Error code description - * @property description Error description (one of the following) - * @property requestId Request id - * @property meta Meta information - * @constructor Create empty Swap error dto - */ -data class SwapErrorDto( - @Json(name = "statusCode") val statusCode: Int, - @Json(name = "error") val error: String, - @Json(name = "description") val description: String, - @Json(name = "requestId") val requestId: String, - @Json(name = "meta") val meta: List, -) - -/** - * Nest error meta - * - * @property type Type of field - * @property value Value of field - */ -data class NestErrorMeta( - @Json(name = "type") val type: String, - @Json(name = "value") val value: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/SwapResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/SwapResponse.kt deleted file mode 100644 index bc1f668360..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/SwapResponse.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -data class SwapResponse( - @Json(name = "fromToken") val fromToken: TokenOneInchDto, - @Json(name = "toToken") val toToken: TokenOneInchDto, - @Json(name = "toAmount") val toTokenAmount: String, - @Json(name = "tx") val transaction: TransactionDto, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TokenOneInchDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TokenOneInchDto.kt deleted file mode 100644 index 0c06f15d84..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TokenOneInchDto.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -/** - * Token one inch - * - * @property symbol token symbol - * @property name token name - * @property address token address - * @property decimals token decimals - * @property logoURI token logo image url - */ -data class TokenOneInchDto( - @Json(name = "symbol") val symbol: String, - @Json(name = "name") val name: String, - @Json(name = "address") val address: String, - @Json(name = "decimals") val decimals: Int, - @Json(name = "logoURI") val logoURI: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TokensResponse.kt deleted file mode 100644 index ca1b369949..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TokensResponse.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -/** - * Tokens response - * - * @property tokens List of supported tokens - */ -data class TokensResponse( - @Json(name = "tokens") val tokens: Map, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TransactionDto.kt b/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TransactionDto.kt deleted file mode 100644 index a32e827601..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/oneinch/models/TransactionDto.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.datasource.api.oneinch.models - -import com.squareup.moshi.Json - -/** - * Transaction dto - * - * @property fromAddress transactions will be sent from this address - * @property toAddress transactions will be sent to our(1inch) contract address - * @property data The encoded data to call the approve method on the swapped token contract - * @property value Native token value in WEI (for approve is always 0) - * @property gasPrice maximum amount of gas for a swap default: 11500000; max: 11500000 - * @property gas estimated amount of the gas limit, increase this value by 25% - * @constructor Create empty Transaction dto - */ -data class TransactionDto( - @Json(name = "from") val fromAddress: String, - @Json(name = "to") val toAddress: String, - @Json(name = "data") val data: String, - @Json(name = "value") val value: String, - @Json(name = "gasPrice") val gasPrice: String, - @Json(name = "gas") val gas: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt b/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt index 3771cc18a6..4ac0edf918 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/ConfigManagerImpl.kt @@ -103,11 +103,9 @@ internal class ConfigManagerImpl @Inject constructor() : ConfigManager { amplitudeApiKey = configValues.amplitudeApiKey, shopify = configValues.shopifyShop, sprinklr = configValues.sprinklr, - swapReferrerAccount = configValues.swapReferrerAccount, walletConnectProjectId = configValues.walletConnectProjectId, tangemComAuthorization = configValues.tangemComAuthorization, - tangemExpressApiKey = configValues.tangemExpressApiKey, - oneInchApiKey = configValues.oneInchApiKey, + express = configValues.express, ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt b/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt index 5ddc1a7187..8490f44237 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/models/Config.kt @@ -16,9 +16,7 @@ data class Config( val isCreatingTwinCardsAllowed: Boolean = false, val shopify: ShopifyShop? = null, val sprinklr: SprinklrConfig? = null, - val swapReferrerAccount: SwapReferrerAccount? = null, val walletConnectProjectId: String = "", val tangemComAuthorization: String? = null, - val tangemExpressApiKey: String = "", - val oneInchApiKey: String = "", + val express: ExpressModel? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/models/ExpressModel.kt b/core/datasource/src/main/java/com/tangem/datasource/config/models/ExpressModel.kt new file mode 100644 index 0000000000..19f97e4d8e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/config/models/ExpressModel.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.config.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ExpressModel( + @Json(name = "apiKey") + val apiKey: String, + @Json(name = "signVerifierPublicKey") + val signVerifierPublicKey: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt b/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt index d63f6527da..e953742589 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/config/models/JsonModels.kt @@ -35,14 +35,12 @@ class ConfigValueModel( val sprinklr: SprinklrConfig?, val tronGridApiKey: String, val amplitudeApiKey: String, - val swapReferrerAccount: SwapReferrerAccount?, val kaspaSecondaryApiUrl: String, val walletConnectProjectId: String, val tangemComAuthorization: String?, val chiaFireAcademyApiKey: String?, val chiaTangemApiKey: String?, - val tangemExpressApiKey: String, - val oneInchApiKey: String, + val express: ExpressModel?, ) @JsonClass(generateAdapter = true) @@ -84,11 +82,6 @@ data class AppsFlyer( val appsFlyerAppID: String, ) -data class SwapReferrerAccount( - val address: String, - val fee: String, -) - class ConfigModel( val features: FeatureModel?, val configValues: ConfigValueModel?, diff --git a/core/datasource/src/main/java/com/tangem/datasource/crypto/DataSignatureVerifier.kt b/core/datasource/src/main/java/com/tangem/datasource/crypto/DataSignatureVerifier.kt new file mode 100644 index 0000000000..1c3a8b68df --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/crypto/DataSignatureVerifier.kt @@ -0,0 +1,6 @@ +package com.tangem.datasource.crypto + +interface DataSignatureVerifier { + + fun verifySignature(signature: String, data: String): Boolean +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/crypto/Sha256SignatureVerifier.kt b/core/datasource/src/main/java/com/tangem/datasource/crypto/Sha256SignatureVerifier.kt new file mode 100644 index 0000000000..dc2c197c50 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/crypto/Sha256SignatureVerifier.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.crypto + +import com.tangem.common.extensions.hexToBytes +import com.tangem.crypto.CryptoUtils +import com.tangem.datasource.config.ConfigManager + +internal class Sha256SignatureVerifier(private val configManager: ConfigManager) : DataSignatureVerifier { + + override fun verifySignature(signature: String, data: String): Boolean { + val pubKey = configManager.config.express?.signVerifierPublicKey ?: return false + return CryptoUtils.verify( + publicKey = pubKey.hexToBytes().takeLast(n = 65).toByteArray(), + message = data.toByteArray(), + signature = signature.hexToBytes(), + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/OneInchApisModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/OneInchApisModule.kt deleted file mode 100644 index c1ffbe4e87..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/di/OneInchApisModule.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.tangem.datasource.di - -import android.content.Context -import com.squareup.moshi.Moshi -import com.tangem.datasource.api.oneinch.OneInchApi -import com.tangem.datasource.api.oneinch.OneInchApiFactory -import com.tangem.datasource.utils.RequestHeader -import com.tangem.datasource.utils.addHeaders -import com.tangem.datasource.utils.addLoggers -import com.tangem.lib.auth.AuthBearerProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import retrofit2.converter.moshi.MoshiConverterFactory -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -class OneInchApisModule { - - @Provides - @Singleton - fun provideOneInchApiFactory( - @NetworkMoshi moshi: Moshi, - @ApplicationContext context: Context, - auth1Inch: AuthBearerProvider, - ): OneInchApiFactory { - val networks = mapOf( - ETH_NETWORK to ONE_INCH_ETH_PATH, - BSC_NETWORK to ONE_INCH_BSC_PATH, - POLYGON_NETWORK to ONE_INCH_POLYGON_PATH, - OPTIMISM_NETWORK to ONE_INCH_OPTIMISM_PATH, - ARBITRUM_NETWORK to ONE_INCH_ARBITRUM_PATH, - GNOSIS_NETWORK to ONE_INCH_GNOSIS_PATH, - AVALANCHE_NETWORK to ONE_INCH_AVALANCHE_PATH, - FANTOM_NETWORK to ONE_INCH_FANTOM_PATH, - ) - - val apiFactory = OneInchApiFactory() - - for ((network, path) in networks) { - apiFactory.putApi( - networkId = network, - api = createOneInchApiWithUrl("$ONE_INCH_BASE_URL$path", moshi, context, auth1Inch), - ) - } - - return apiFactory - } - - private fun createOneInchApiWithUrl( - url: String, - moshi: Moshi, - context: Context, - auth1Inch: AuthBearerProvider, - ): OneInchApi { - return Retrofit.Builder() - .addConverterFactory( - MoshiConverterFactory.create(moshi), - ) - .baseUrl(url) - .client( - OkHttpClient.Builder() - .addHeaders(RequestHeader.AuthBearerHeader(auth1Inch)) - .addLoggers(context) - .build(), - ) - .build() - .create(OneInchApi::class.java) - } - - companion object { - private const val ONE_INCH_BASE_URL = "https://api.1inch.dev/swap/v5.2/" - private const val ONE_INCH_ETH_PATH = "1/" - private const val ONE_INCH_BSC_PATH = "56/" - private const val ONE_INCH_POLYGON_PATH = "137/" - private const val ONE_INCH_OPTIMISM_PATH = "10/" - private const val ONE_INCH_ARBITRUM_PATH = "42161/" - private const val ONE_INCH_GNOSIS_PATH = "100/" - private const val ONE_INCH_AVALANCHE_PATH = "43114/" - private const val ONE_INCH_FANTOM_PATH = "250/" - - private const val ETH_NETWORK = "ethereum" - private const val BSC_NETWORK = "binance-smart-chain" - private const val POLYGON_NETWORK = "polygon-pos" - private const val OPTIMISM_NETWORK = "optimistic-ethereum" - private const val ARBITRUM_NETWORK = "arbitrum-one" - private const val GNOSIS_NETWORK = "xdai" - private const val AVALANCHE_NETWORK = "avalanche" - private const val FANTOM_NETWORK = "fantom" - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt new file mode 100644 index 0000000000..b5c2f07c49 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.crypto.DataSignatureVerifier +import com.tangem.datasource.crypto.Sha256SignatureVerifier +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object SecurityModule { + + @Provides + @Singleton + fun provideDataSignatureVerifier(configManager: ConfigManager): DataSignatureVerifier { + return Sha256SignatureVerifier(configManager) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/StatusCodeInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/di/StatusCodeInterceptor.kt new file mode 100644 index 0000000000..4da4c8b17d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/StatusCodeInterceptor.kt @@ -0,0 +1,52 @@ +package com.tangem.datasource.di + +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import timber.log.Timber + +class StatusCodeInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalResponse = chain.proceed(chain.request()) + + if (shouldInterceptResponse(originalResponse)) { + Timber.e("StatusCodeInterceptor INTERCEPTED%s", originalResponse.request.url.toString()) + + val body = getBody().toResponseBody("application/json".toMediaTypeOrNull()) + val code = getCode() + + return originalResponse.newBuilder() + .code(code) + .body(body) + .build() + } + + return originalResponse + } + + private fun shouldInterceptResponse(response: Response): Boolean { + return response.request.url.toString().contains("exchange-quote") + // && response.request.url.toString().contains("changenow") + } + + private fun getCode(): Int { + return CODE_400 + } + + private fun getBody(): String { + return "{\n" + + " \"error\": {\n" + + " \"code\": 2250,\n" + + " \"description\": \"Core: exchange too small amount\",\n" + + " \"message\": \"Not valid\",\n" + + " \"minAmount\": 5\n" + + " }\n" + + "}" + } + + companion object { + private const val CODE_400 = 400 + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt index 1619c322de..0c38cd669b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt @@ -32,6 +32,11 @@ class AppPreferencesStore( return edit { transform(it) } } + /** Get data [T] by [key]. If data is not found, it returns [default] */ + inline fun MutablePreferences.getOrDefault(key: Preferences.Key, default: T): T { + return this[key] ?: default + } + /** * Get nullable data [T] by string [key] from [MutablePreferences] * diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index d10a9dab4f..02432a3d03 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -35,6 +35,8 @@ object PreferencesKeys { val SWAP_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "swapTransactions") } + val SWAP_TRANSACTIONS_STATUSES_KEY by lazy { stringPreferencesKey(name = "swapTransactionsStatuses") } + val WALLETS_SCROLL_PREVIEW_KEY by lazy { booleanPreferencesKey(name = "walletsScrollPreview") } val SENT_ONE_TIME_EVENTS_KEY by lazy { stringPreferencesKey(name = "sentOneTimeEvents") } @@ -42,6 +44,10 @@ object PreferencesKeys { val WALLETS_BALANCES_STATES_KEY by lazy { stringPreferencesKey(name = "walletsBalancesStates") } val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") } + + val IS_WALLET_SWAP_PROMO_SHOW_KEY by lazy { booleanPreferencesKey(name = "isWalletSwapPromoShown") } + + val IS_TOKEN_SWAP_PROMO_SHOW_KEY by lazy { booleanPreferencesKey(name = "isTokenSwapPromoShown") } } /** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore */ diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt index c0fc7e0cef..cd1facee55 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt @@ -2,6 +2,7 @@ package com.tangem.datasource.local.preferences.utils import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import com.squareup.moshi.JsonDataException import com.squareup.moshi.Types import com.tangem.datasource.local.preferences.AppPreferencesStore import kotlinx.coroutines.flow.Flow @@ -11,7 +12,15 @@ import kotlinx.coroutines.flow.map /** Get flow of nullable data [T] by string [key] */ inline fun AppPreferencesStore.getObject(key: Preferences.Key): Flow { val adapter = moshi.adapter(T::class.java) - return data.map { it[key]?.let(adapter::fromJson) } + return data.map { preferences -> + preferences[key]?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + null + } + } + } } /** @@ -23,7 +32,13 @@ inline fun AppPreferencesStore.getObject(key: Preferences.Key AppPreferencesStore.getObject(key: Preferences.Key, default: T): Flow { val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - return data.map { it[key]?.let(adapter::fromJson) ?: default } + return data.map { + try { + it[key]?.let(adapter::fromJson) ?: default + } catch (e: JsonDataException) { + default + } + } } /** @@ -37,7 +52,13 @@ suspend inline fun AppPreferencesStore.getObjectSyncOrNull(key: Pref val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types return data.firstOrNull() ?.get(key) - ?.let(adapter::fromJson) + ?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + null + } + } } /** Get data [T] by string [key]. If data is not found, it returns [default] */ @@ -48,7 +69,13 @@ suspend inline fun AppPreferencesStore.getObjectSyncOrDefault( val adapter = moshi.adapter(T::class.java) return data.firstOrNull() ?.get(key) - ?.let(adapter::fromJson) + ?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + default + } + } ?: default } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/SwapTransactionStatusStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/SwapTransactionStatusStore.kt index 59bd30030e..2bcb9a3dbb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/SwapTransactionStatusStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/SwapTransactionStatusStore.kt @@ -15,4 +15,5 @@ enum class ExchangeAnalyticsStatus(val value: String) { Fail("Fail"), KYC("KYC"), Refunded("Refunded"), + Cancelled("Canceled"), } \ No newline at end of file diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 530ffd016c..254dd85c1e 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -190,25 +190,50 @@ Условия использования К сожалению, текущая версия приложения не готова к работе с этой картой, проверьте наличие обновлений Вы использовали карту от другого кошелька. Приложите карту, связанную с этим кошельком. - Вы получаете - Вы отправляете Мои токены У вас нет добавленных токенов. Добавьте токены для обмена + Токены не найдены. Пожалуйста, попробуйте другой запрос Недоступен для обмена с %s + Кроме того, в курс обмена включена комиссия сети за отправку обмененных средств на ваш адрес Статус Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами Выберите провайдера - Чтобы узнать причину, посетите сайт провайдера - Чтобы вернуть ваши деньги, посетите сайт провайдера + Курс обмена + Больше провайдеров на подходе.\nСледите за обновлениями! + Чтобы вернуть ваши деньги, посетите сайт провайдера + Операция не выполнена провайдером Посетите сайт провайдера для проверки Провайдер запрашивает прохождение верификации + Отменен + Подтверждено + Подтверждение + Подтверждение... + Обменяно + Обмен + Обмен... + Неудачно + Депозит получен + Ожидание депозита + Ожидаем пополнения... + Возвращено + Отправляем + Отправка средств... + Отправлено + Верифицировано + Требуется верификация Список токенов в вашем кошельке Получение наилучших курсов... + Плавающая ставка + К провайдеру + Пользуясь сервисом вы соглашаетесь с %s + Пользуясь сервисом вы соглашаетесь с %1$s и %2$s + Политикой конфиденциальности Провайдер Лучший курс Доступно с %s Недоступно для этой пары Требуется разрешение + Условиями использования Информация ниже не является обязательной. Вы можете стереть её, если хотите. Расскажите, каких функций вам не хватает, и мы постараемся вам помочь. Скажите, пожалуйста, какая у вас карта? @@ -245,6 +270,8 @@ 1INCH токены будут зачислены на адрес вашего кошелька в сети %s в течение 48 часов Чтобы получить доступ ко всем сетям, вам необходимо отсканировать карту Отсканируйте карту + Обменивайте свои цифровые активы между различными сетями + Кроссчейн-своп теперь доступен Токены Добавить Изменить @@ -497,6 +524,7 @@ Подтверждения считаются отраслевым стандартом для всех децентрализованных бирж и защищают ваш кошелек от доступа со стороны смарт-контракта без вашего разрешения. По замыслу смарт-контракты не могут получить доступ к вашим токенам, если вы не одобрите доступ со своей стороны. «Разблокируя» свои токены, вы даете смарт-контракту 1inch разрешение тратить ваши активы. Майнеры сети получают компенсацию за газ (оплачиваемый вами) за запись этого действия в блокчейне. Как только разрешение будет предоставлено, вы сможете обменять свой токен. Подтвердить Ошибка: %s + Вы отправляете Произошла ошибка. Пожалуйста, попробуйте еще раз. Дать разрешение Сильные колебания цены! @@ -520,6 +548,7 @@ Обменять Обмен %s на Котировки включают дополнительную комиссию Tangem в размере %s. Это помогает нам предоставлять первоклассный продукт. + Вы получите Другие токены Выберите токен Ваши токены @@ -541,6 +570,9 @@ Токен %1$s является основной валютой в сети %2$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети. Невозможно скрыть %s Нет цены + Обменять + Обменяйте этот токен на другие активы в вашем портфеле + Представляем кроссчейн-своп контракт: %s У вас еще нет транзакций Не удалось загрузить историю транзакций.\nНажмите на кнопку перезагрузки, чтобы обновить информацию. @@ -649,8 +681,12 @@ Не для пользователя! Cеть %1$s использует концепцию экзистенциального депозита. Если баланс вашего счета будет ниже %2$s, то он будет деактивирован, а средства на счете уничтожены. Для работы с сетью необходим депозит + Обмен будет доступен после завершения %s транзакции + У вас есть активная транзакция У вас в списке нет монет доступных для обмена с %s Нет доступных токенов для обмена + Чтобы совершить транзакцию, вам необходимо внести немного %1$s %2$s + Невозможно покрыть комиссию %s Cервис временно недоступен Пожалуйста, измените сумму для обмена Сумма для обмена должна быть не менее %s @@ -697,4 +733,7 @@ Сканировать карту Используйте %s или отсканируйте карту для входа в приложение C возвращением! + Обмен через %s + Данные провайдера. Сумма к получению может измениться в зависимости от рыночных условий. + Статус обмена diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index f595e68773..adb5903417 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -137,8 +137,6 @@ 服務條款 糟糕,當前版本的應用程序無法使用此卡,請檢查更新 您使用了另一個錢包中的卡。點按與此錢包關聯的卡片 - 您收到 - 您發送 以下信息是可選的。如果不想共享,可以將其刪除 告訴我們您缺少哪些功能,我們會盡力幫助您 請告訴我們你有什麼卡 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 414edfc00e..f4187be85b 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -189,20 +189,22 @@ Terms of Service Oops, the current version of the application is not ready to work with this card, please check for updates. You have used a card from another wallet. Tap the card associated with this wallet - You receive - You send My tokens You haven\'t added any tokens yet. Add tokens via Market to swap + No tokens found. Please try another request Cannot be swapped for %s + Additionally, the network fee for sending the exchanged funds back to your address is included in the rate Status Providers facilitate transactions, ensuring smooth and efficient token swaps Choose provider + More providers are coming soon.\nStay tuned! Estimated amount Exchange by %s Visit provider’s website to refund your money Operation failed by provider Visit provider’s website for verification KYC verification required by provider + Canceled Confirmed Confirming Confirming… @@ -217,18 +219,23 @@ Sending to you Sending to you… Sent - Provider-sourced data. Estimated amount subject to change. + Provider-sourced data. Estimated amount subject to change due to market conditions. Exchange status Verified Verification required List of all tokens added to your wallet Fetching best rates... Floating rate + Go to provider + By using swap functionality, you agree with provider’s %s + By using swap functionality, you agree with provider’s %1$s and %2$s + Privacy Policy Provider Best rate Available from %s Unavailable for this pair - Permission Needed + Permission Required + Terms of Use The following information is optional. You can erase it if you don\'t want to share it. Tell us what functions you are missing, and we will try to help you. Please tell us what card do you have @@ -263,6 +270,8 @@ 1INCH tokens will be credited to your %s wallet address within 48 hours To access all the networks you need to scan the card Scan your card + Swap multiple currencies across several blockchains + Cross-chain swaps are now available Tokens Add Edit @@ -528,6 +537,7 @@ All decentralized exchanges require approvals to prevent smart contracts from accessing your wallet without your permission. By design, smart contracts can\'t access your tokens unless you approve. By \"unlocking\" your tokens, you authorize the 1-inch smart contract to spend them. The network\'s miners receive a gas fee (paid by you) to record this action on the blockchain. You can swap your token after giving approval. Approve Error: %s + You swap There was an error. Please try again. Give Permission High price impact! @@ -546,13 +556,12 @@ Your wallet To continue, grant 1inch smart contracts permission to use your %s Unlimited - You swap - You receive View in Explorer In progress Swap Swap %s for Quotes include an additional Tangem commission of %s. This helps us deliver a top-of-the-line product. + You receive Other tokens Choose token Your tokens @@ -572,6 +581,9 @@ The %1$s token is the main currency on the %2$s network and cannot be hidden as long as you have other tokens on this network in the list. Unable to hide %s No rate + Exchange this token for other assets in your portfolio + Introducing cross-chain swaps + Swap now contract: %s You don\'t have any transactions yet Failed to load transaction history.\nClick on reload button to update the information. @@ -680,11 +692,13 @@ Not for users! %1$s network requires an Existential Deposit. If your account drops below %2$s, it will be deactivated, and any remaining funds will be destroyed. Network requires Existential Deposit + Swap will be available after the %s transaction is complete + You have active transaction You do not have any %s exchangeable coins in your list No available tokens to swap To make a transaction you need to deposit some %1$s %2$s Unable to cover %s fee - Service temporary unavailable + Service temporarily unavailable Please change the amount to swap The amount to swap must be at least %s This card might be a production sample or counterfeit diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt index 4baf118b61..a920fda100 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Keyboard.kt @@ -1,13 +1,10 @@ package com.tangem.core.ui.components import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.navigationBars import androidx.compose.runtime.Composable import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -25,23 +22,9 @@ sealed interface Keyboard { /** * Allows to subscribe to a soft keyboard to detect when it's open/closed */ -@Deprecated("Use Modifier.imePadding() on pure Compose screens (without XML layouts)") @Composable fun keyboardAsState(): State { - val density = LocalDensity.current - val imeInsets = WindowInsets.ime - .exclude(WindowInsets.navigationBars) - .getBottom(density) - - return remember(imeInsets) { - derivedStateOf { - if (imeInsets > 0) { - Keyboard.Opened( - height = with(density) { imeInsets.toDp() }, - ) - } else { - Keyboard.Closed - } - } - } + val bottom = WindowInsets.ime.getBottom(LocalDensity.current) + val isImeVisible = bottom > 0 + return rememberUpdatedState(if (isImeVisible) Keyboard.Opened(bottom.dp) else Keyboard.Closed) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithSearch.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithSearch.kt index 867774adfb..4dfd01d36d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithSearch.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithSearch.kt @@ -95,7 +95,6 @@ private fun CollapsedSearchView( modifier = Modifier .background(TangemTheme.colors.background.secondary) .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), verticalAlignment = Alignment.CenterVertically, ) { Icon( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonSize.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonSize.kt index 0370fe0efe..bfae5cb313 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonSize.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemButtonSize.kt @@ -111,7 +111,7 @@ internal fun TangemButtonSize.toHorizontalContentPadding(icon: TangemButtonIconP TangemButtonSize.Default, TangemButtonSize.WideAction, TangemButtonSize.TwoLines, - -> TangemTheme.dimens.spacing32 to TangemTheme.dimens.spacing32 + -> TangemTheme.dimens.spacing16 to TangemTheme.dimens.spacing16 TangemButtonSize.Text -> when (icon) { is TangemButtonIconPosition.None -> TangemTheme.dimens.spacing16 to TangemTheme.dimens.spacing16 is TangemButtonIconPosition.Start -> TangemTheme.dimens.spacing14 to TangemTheme.dimens.spacing16 diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt index 1992043a9e..c6180da951 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationConfig.kt @@ -6,12 +6,13 @@ import com.tangem.core.ui.extensions.TextReference /** * Notification component state * - * @property title title - * @property subtitle subtitle - * @property iconResId icon resource id - * @property buttonsState buttons state - * @property onClick lambda be invoked when notification is clicked - * @property onCloseClick lambda be invoked when close button is clicked + * @property title title + * @property subtitle subtitle + * @property iconResId icon resource id + * @property backgroundResId background resource id + * @property buttonsState buttons state + * @property onClick lambda be invoked when notification is clicked + * @property onCloseClick lambda be invoked when close button is clicked * [REDACTED_AUTHOR] */ @@ -19,6 +20,7 @@ data class NotificationConfig( val title: TextReference, val subtitle: TextReference, @DrawableRes val iconResId: Int, + @DrawableRes val backgroundResId: Int? = null, val buttonsState: ButtonsState? = null, val onClick: (() -> Unit)? = null, val onCloseClick: (() -> Unit)? = null, @@ -33,7 +35,11 @@ data class NotificationConfig( val onClick: () -> Unit, ) : ButtonsState() - data class SecondaryButtonConfig(val text: TextReference, val onClick: () -> Unit) : ButtonsState() + data class SecondaryButtonConfig( + val text: TextReference, + @DrawableRes val iconResId: Int? = null, + val onClick: () -> Unit, + ) : ButtonsState() data class PairButtonsConfig( val primaryText: TextReference, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationWithBackground.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationWithBackground.kt new file mode 100644 index 0000000000..691cded7ba --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/NotificationWithBackground.kt @@ -0,0 +1,196 @@ +package com.tangem.core.ui.components.notifications + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.constraintlayout.compose.Visibility +import com.tangem.core.ui.R +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonColors +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalIsInDarkTheme +import com.tangem.core.ui.res.TangemColorPalette.Dark6 +import com.tangem.core.ui.res.TangemColorPalette.Light4 +import com.tangem.core.ui.res.TangemTheme + +/** + * Custom notification with image background + * @see [Swap Promo](https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?type=design&node-id=8713-6307&mode=dev) + */ +@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries") +@Composable +fun NotificationWithBackground(config: NotificationConfig, modifier: Modifier = Modifier) { + val button = config.buttonsState as? NotificationConfig.ButtonsState.SecondaryButtonConfig + + ConstraintLayout( + modifier = modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size62) + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium), + ) { + val (iconRef, titleRef, subtitleRef, closeIconRef, buttonRef, backgroundRef) = createRefs() + + val spacing2 = TangemTheme.dimens.spacing2 + val spacing12 = TangemTheme.dimens.spacing12 + val spacing14 = TangemTheme.dimens.spacing14 + + Image( + painter = painterResource(config.backgroundResId ?: R.drawable.img_swap_promo_banner_background), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.constrainAs(backgroundRef) { + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints + height = Dimension.fillToConstraints + visibility = if (config.backgroundResId == null) Visibility.Gone else Visibility.Visible + }, + ) + Image( + painter = painterResource(config.iconResId), + contentDescription = null, + modifier = Modifier + .size(TangemTheme.dimens.size34) + .constrainAs(iconRef) { + start.linkTo(parent.start, spacing12) + linkTo(titleRef.top, subtitleRef.bottom, bias = 0.1f) + }, + ) + Text( + text = config.title.resolveReference(), + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.constantWhite, + modifier = Modifier.constrainAs(titleRef) { + top.linkTo(parent.top, spacing12) + start.linkTo(iconRef.end, spacing12) + end.linkTo(closeIconRef.start, spacing2) + width = Dimension.fillToConstraints + }, + ) + Text( + text = config.subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.constantWhite, + modifier = Modifier.constrainAs(subtitleRef) { + top.linkTo(titleRef.bottom, spacing2) + start.linkTo(iconRef.end, spacing12) + end.linkTo(parent.end, spacing12) + bottom.linkTo(buttonRef.top, spacing12, spacing14) + width = Dimension.fillToConstraints + }, + ) + Icon( + painter = painterResource(id = R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors.text.constantWhite, + modifier = Modifier + .size(TangemTheme.dimens.size16) + .constrainAs(closeIconRef) { + top.linkTo(parent.top, spacing12) + end.linkTo(parent.end, spacing12) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + ) { + config.onCloseClick?.invoke() + }, + ) + val isDarkMode = LocalIsInDarkTheme.current + TangemButton( + text = button?.text?.resolveReference().orEmpty(), + icon = TangemButtonIconPosition.Start(button?.iconResId ?: R.drawable.ic_exchange_vertical_24), + onClick = button?.onClick ?: {}, + colors = TangemButtonColors( + backgroundColor = if (isDarkMode) Light4 else TangemTheme.colors.button.secondary, + contentColor = Dark6, + disabledBackgroundColor = TangemTheme.colors.button.disabled, + disabledContentColor = TangemTheme.colors.text.disabled, + ), + enabled = true, + showProgress = false, + modifier = Modifier.constrainAs(buttonRef) { + start.linkTo(parent.start, spacing12) + end.linkTo(parent.end, spacing12) + bottom.linkTo(parent.bottom, spacing12) + width = Dimension.fillToConstraints + visibility = if (button == null) { + Visibility.Gone + } else { + Visibility.Visible + } + }, + ) + } +} + +//region preview +@Preview +@Composable +private fun NotificationWithBackgroundPreview_Light( + @PreviewParameter(NotificationWithBackgroundPreviewProvider::class) config: NotificationConfig, +) { + TangemTheme { + NotificationWithBackground(config = config) + } +} + +@Preview +@Composable +private fun NotificationWithBackgroundPreview_Dark( + @PreviewParameter(NotificationWithBackgroundPreviewProvider::class) config: NotificationConfig, +) { + TangemTheme(isDark = true) { + NotificationWithBackground(config = config) + } +} + +private class NotificationWithBackgroundPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + NotificationConfig( + title = resourceReference(id = R.string.main_swap_promotion_title), + subtitle = resourceReference(id = R.string.main_swap_promotion_message), + iconResId = R.drawable.img_swap_promo, + backgroundResId = R.drawable.img_swap_promo_banner_background, + ), + NotificationConfig( + title = resourceReference(id = R.string.token_swap_promotion_title), + subtitle = stringReference( + "Swap multiple currencies between any chains you wish. Swap multiple " + + "currencies between any chains you wish. Swap multiple " + + "currencies between any chains you wish. Commission free period " + + "till Dec 31.", + ), + iconResId = R.drawable.img_swap_promo, + backgroundResId = R.drawable.img_swap_promo_banner_background, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(id = R.string.token_swap_promotion_button), + onClick = {}, + ), + ), + ) +} +//endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt index d576eafe66..2adcbdc369 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt @@ -44,7 +44,7 @@ fun TransactionDoneTitle(@StringRes titleRes: Int, date: Long, modifier: Modifie style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, modifier = Modifier - .padding(top = TangemTheme.dimens.spacing32), + .padding(top = TangemTheme.dimens.spacing16), ) Text( text = stringResource(id = R.string.send_date_format, date.toDateFormat(), date.toTimeFormat()), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt index 202930549a..6b4cc1bd3d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt @@ -71,7 +71,6 @@ fun LazyListScope.txHistoryItems( } } -@OptIn(ExperimentalFoundationApi::class) private fun LazyListScope.contentItems( txHistoryItems: LazyPagingItems, isBalanceHidden: Boolean, @@ -93,7 +92,6 @@ private fun LazyListScope.contentItems( state = item, isBalanceHidden = isBalanceHidden, modifier = modifier - .animateItemPlacement() .roundedShapeItemDecoration( currentIndex = index, lastIndex = txHistoryItems.itemSnapshotList.lastIndex, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index d2c890aad3..f87f80fe61 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -175,7 +175,7 @@ private fun darkThemeColors(): TangemColors { key = TangemColorPalette.White, ), stroke = TangemColors.Stroke( - primary = TangemColorPalette.Dark5, + primary = TangemColorPalette.Dark4, secondary = TangemColorPalette.Dark1, transparency = TangemColorPalette.Dark6, ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt index c64c860e28..e2d0c8dc42 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt @@ -22,7 +22,13 @@ object BigDecimalFormatter { roundingMode = RoundingMode.DOWN } - return formatter.format(cryptoAmount) + "\u2009$cryptoCurrency" + return formatter.format(cryptoAmount).let { + if (cryptoCurrency.isEmpty()) { + it + } else { + it + "\u2009$cryptoCurrency" + } + } } fun formatCryptoAmount(cryptoAmount: BigDecimal?, cryptoCurrency: CryptoCurrency): String { diff --git a/core/ui/src/main/res/drawable/img_swap_promo.webp b/core/ui/src/main/res/drawable/img_swap_promo.webp new file mode 100644 index 0000000000..4f5bd02d24 Binary files /dev/null and b/core/ui/src/main/res/drawable/img_swap_promo.webp differ diff --git a/core/ui/src/main/res/drawable/img_swap_promo_banner_background.webp b/core/ui/src/main/res/drawable/img_swap_promo_banner_background.webp new file mode 100644 index 0000000000..1e952cd6f7 Binary files /dev/null and b/core/ui/src/main/res/drawable/img_swap_promo_banner_background.webp differ diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSwapPromoRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSwapPromoRepository.kt new file mode 100644 index 0000000000..8ba9fe211e --- /dev/null +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSwapPromoRepository.kt @@ -0,0 +1,62 @@ +package com.tangem.data.settings + +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TOKEN_SWAP_PROMO_SHOW_KEY +import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PROMO_SHOW_KEY +import com.tangem.datasource.local.preferences.utils.get +import com.tangem.datasource.local.preferences.utils.store +import com.tangem.domain.settings.repositories.SwapPromoRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.Calendar + +/** + * Repository for showing swap promo notification. + */ +class DefaultSwapPromoRepository( + private val appPreferencesStore: AppPreferencesStore, +) : SwapPromoRepository { + override fun isReadyToShowWallet(): Flow { + return appPreferencesStore.get(IS_WALLET_SWAP_PROMO_SHOW_KEY, true) + .map { it && checkPromoPeriod() } + } + + override fun isReadyToShowToken(): Flow { + return appPreferencesStore.get(IS_TOKEN_SWAP_PROMO_SHOW_KEY, true) + .map { it && checkPromoPeriod() } + } + + override suspend fun setNeverToShowWallet() { + appPreferencesStore.store( + key = IS_WALLET_SWAP_PROMO_SHOW_KEY, + value = false, + ) + } + + override suspend fun setNeverToShowToken() { + appPreferencesStore.store( + key = IS_TOKEN_SWAP_PROMO_SHOW_KEY, + value = false, + ) + } + + private suspend fun checkPromoPeriod(): Boolean { + val calendar = Calendar.getInstance() + val currentTime = calendar.timeInMillis + calendar.set(END_YEAR_KEY, END_MONTH_KEY, END_DAY_KEY, 0, 0, 0) + val endTime = calendar.timeInMillis + + val shouldShow = endTime - currentTime > 0 + if (!shouldShow) { + setNeverToShowToken() + setNeverToShowWallet() + } + return shouldShow + } + + companion object { + private const val END_DAY_KEY = 1 + private const val END_MONTH_KEY = 1 // February + private const val END_YEAR_KEY = 2024 + } +} \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt index ba83b09d4d..43d5796d15 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt @@ -2,10 +2,12 @@ package com.tangem.data.settings.di import com.tangem.data.settings.DefaultAppRatingRepository import com.tangem.data.settings.DefaultSettingsRepository +import com.tangem.data.settings.DefaultSwapPromoRepository import com.tangem.data.source.preferences.PreferencesDataSource import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.settings.repositories.SwapPromoRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -36,4 +38,10 @@ internal object SettingsDataModule { fun provideAppRatingRepository(appPreferencesStore: AppPreferencesStore): AppRatingRepository { return DefaultAppRatingRepository(appPreferencesStore = appPreferencesStore) } + + @Provides + @Singleton + fun provideSwapPromoRepository(appPreferencesStore: AppPreferencesStore): SwapPromoRepository { + return DefaultSwapPromoRepository(appPreferencesStore = appPreferencesStore) + } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultMarketCryptoCurrencyRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultMarketCryptoCurrencyRepository.kt index 84188ea775..a210d96273 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultMarketCryptoCurrencyRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultMarketCryptoCurrencyRepository.kt @@ -11,6 +11,10 @@ class DefaultMarketCryptoCurrencyRepository( ) : MarketCryptoCurrencyRepository { override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { + return getExchangeableFlag(userWalletId, cryptoCurrency) && !cryptoCurrency.isCustom + } + + private suspend fun getExchangeableFlag(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE return assetsStore.getSyncOrNull(userWalletId)?.find { diff --git a/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt b/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt index 61170a1927..58c0bb0dde 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt @@ -7,5 +7,7 @@ interface ReduxStateHolder { fun dispatch(action: Action) + suspend fun dispatchWithMain(action: Action) + suspend fun onUserWalletSelected(userWallet: UserWallet) } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt index 62a1b15160..1eaefab578 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -10,11 +10,13 @@ import com.tangem.blockchain.blockchains.solana.RentProvider import com.tangem.blockchain.common.* import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType +import com.tangem.blockchain.common.address.EstimationFeeAddressFactory import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.txhistory.TransactionHistoryRequest import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.crypto.bip39.Mnemonic import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.datasource.asset.AssetReader import com.tangem.datasource.config.ConfigManager @@ -45,6 +47,7 @@ class DefaultWalletManagersFacade( private val walletManagersStore: WalletManagersStore, private val userWalletsStore: UserWalletsStore, configManager: ConfigManager, + mnemonic: Mnemonic, assetReader: AssetReader, moshi: Moshi, ) : WalletManagersFacade { @@ -55,6 +58,7 @@ class DefaultWalletManagersFacade( private val sdkTokenConverter by lazy { SdkTokenConverter() } private val txHistoryStateConverter by lazy { SdkTransactionHistoryStateConverter() } private val txHistoryItemConverter by lazy { SdkTransactionHistoryItemConverter(assetReader, moshi) } + private val estimationFeeAddressFactory by lazy { EstimationFeeAddressFactory(mnemonic) } override suspend fun update( userWalletId: UserWalletId, @@ -247,7 +251,7 @@ class DefaultWalletManagersFacade( derivationPath = derivationPath, ) if (walletManager == null || blockchain == Blockchain.Unknown) { - Timber.w("Unable to get a wallet manager for blockchain: $blockchain") + Timber.w("Unable to create or find a wallet manager for blockchain: $blockchain") return UpdateWalletManagerResult.UnreachableWithoutAddresses } @@ -433,6 +437,26 @@ class DefaultWalletManagersFacade( ) } + override suspend fun estimateFee( + amount: Amount, + userWalletId: UserWalletId, + network: Network, + ): Result? { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + + val destination = estimationFeeAddressFactory.makeAddress(blockchain) + + return (walletManager as? TransactionSender)?.estimateFee( + amount = amount, + destination = destination, + ) + } + override suspend fun validateTransaction( amount: Amount, fee: Amount?, diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt index cd614532f9..922c354dde 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -167,6 +167,15 @@ interface WalletManagersFacade { network: Network, ): Result? + /** + * Returns estimated fee for transaction + * + * @param amount of transaction + * @param userWalletId selected wallet id + * @param network network of currency + */ + suspend fun estimateFee(amount: Amount, userWalletId: UserWalletId, network: Network): Result? + /** * Validates transaction * diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/WalletManagerFactory.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/WalletManagerFactory.kt index cd0b2a670b..9e83e20e3a 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/WalletManagerFactory.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/utils/WalletManagerFactory.kt @@ -10,6 +10,7 @@ import com.tangem.domain.common.DerivationStyleProvider import com.tangem.domain.common.extensions.makeWalletManagerForApp import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.models.scan.ScanResponse +import timber.log.Timber internal class WalletManagerFactory( private val configManager: ConfigManager, @@ -26,11 +27,16 @@ internal class WalletManagerFactory( ): WalletManager? { val derivationParams = getDerivationParams(derivationPath, scanResponse.derivationStyleProvider) - return sdkWalletManagerFactory.makeWalletManagerForApp( - scanResponse = scanResponse, - blockchain = blockchain, - derivationParams = derivationParams, - ) + return try { + sdkWalletManagerFactory.makeWalletManagerForApp( + scanResponse = scanResponse, + blockchain = blockchain, + derivationParams = derivationParams, + ) + } catch (e: Throwable) { + Timber.w(e, "Failed to create wallet manager for $blockchain") + null + } } private fun getDerivationParams( diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoTokenUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoTokenUseCase.kt new file mode 100644 index 0000000000..0c75a4f52a --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoTokenUseCase.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.settings + +import com.tangem.domain.settings.repositories.SwapPromoRepository +import kotlinx.coroutines.flow.Flow + +class ShouldShowSwapPromoTokenUseCase(private val swapPromoRepository: SwapPromoRepository) { + + operator fun invoke(): Flow = swapPromoRepository.isReadyToShowToken() + + suspend fun neverToShow() = swapPromoRepository.setNeverToShowToken() +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoWalletUseCase.kt b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoWalletUseCase.kt new file mode 100644 index 0000000000..573a5ea94b --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/ShouldShowSwapPromoWalletUseCase.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.settings + +import com.tangem.domain.settings.repositories.SwapPromoRepository +import kotlinx.coroutines.flow.Flow + +class ShouldShowSwapPromoWalletUseCase(private val swapPromoRepository: SwapPromoRepository) { + + operator fun invoke(): Flow = swapPromoRepository.isReadyToShowWallet() + + suspend fun neverToShow() = swapPromoRepository.setNeverToShowWallet() +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SwapPromoRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SwapPromoRepository.kt new file mode 100644 index 0000000000..95d31f65d8 --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SwapPromoRepository.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.settings.repositories + +import kotlinx.coroutines.flow.Flow + +interface SwapPromoRepository { + fun isReadyToShowWallet(): Flow + + fun isReadyToShowToken(): Flow + + suspend fun setNeverToShowWallet() + + suspend fun setNeverToShowToken() +} \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 8fabb669f0..e32512bb66 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -18,6 +18,9 @@ dependencies { implementation(projects.domain.txhistory.models) implementation(projects.domain.wallets.models) implementation(projects.domain.appCurrency.models) + implementation(projects.domain.settings) + implementation(projects.features.swap.domain.api) + implementation(projects.features.swap.domain.models) /** Project - Other */ implementation(projects.core.utils) diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt index f6c14d8482..d818e80eea 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt @@ -31,4 +31,6 @@ sealed class CryptoCurrencyWarning { data class Rent(val rent: BigDecimal, val exemptionAmount: BigDecimal) : CryptoCurrencyWarning() data class HasPendingTransactions(val blockchainSymbol: String) : CryptoCurrencyWarning() + + object SwapPromo : CryptoCurrencyWarning() } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenSwapPromoAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenSwapPromoAnalyticsEvent.kt new file mode 100644 index 0000000000..cc39985633 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenSwapPromoAnalyticsEvent.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.tokens.models.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +sealed class TokenSwapPromoAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Swap Promo", event, params, null) { + + object Close : TokenSwapPromoAnalyticsEvent(event = "Button - Close") + + class Exchange( + token: String, + ) : TokenSwapPromoAnalyticsEvent( + event = "Button - Exchange Now", + params = mapOf("Token" to token), + ) +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt index 87dee2d3d9..2a0d6ad530 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt @@ -1,6 +1,5 @@ package com.tangem.domain.tokens -import com.tangem.domain.common.CardTypesResolver import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.tokens.model.CryptoCurrency @@ -12,7 +11,6 @@ import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.isNullOrZero import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -53,10 +51,9 @@ class GetCryptoCurrencyActionsUseCase( val flow = networkFlow.mapLatest { maybeCoinStatus -> createTokenActionsState( - userWalletId = userWallet.walletId, + userWallet = userWallet, coinStatus = maybeCoinStatus.getOrNull(), cryptoCurrencyStatus = cryptoCurrencyStatus, - cardTypesResolver = userWallet.scanResponse.cardTypesResolver, ) } @@ -65,19 +62,17 @@ class GetCryptoCurrencyActionsUseCase( } private suspend fun createTokenActionsState( - userWalletId: UserWalletId, + userWallet: UserWallet, coinStatus: CryptoCurrencyStatus?, cryptoCurrencyStatus: CryptoCurrencyStatus, - cardTypesResolver: CardTypesResolver, ): TokenActionsState { return TokenActionsState( - walletId = userWalletId, + walletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus, states = createListOfActions( - userWalletId, + userWallet, coinStatus, cryptoCurrencyStatus, - cardTypesResolver, ), ) } @@ -87,10 +82,9 @@ class GetCryptoCurrencyActionsUseCase( * Actions priority: [Buy Send Receive Sell Swap] */ private suspend fun createListOfActions( - userWalletId: UserWalletId, + userWallet: UserWallet, coinStatus: CryptoCurrencyStatus?, cryptoCurrencyStatus: CryptoCurrencyStatus, - cardTypesResolver: CardTypesResolver, ): List { val cryptoCurrency = cryptoCurrencyStatus.currency if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.MissedDerivation) { @@ -116,14 +110,13 @@ class GetCryptoCurrencyActionsUseCase( activeList.add(TokenActionsState.ActionState.Send(true)) } - val isMulticurrencyWallet = cardTypesResolver.isTangemWallet() || cardTypesResolver.isWallet2() // swap - if (isMulticurrencyWallet && - marketCryptoCurrencyRepository.isExchangeable(userWalletId, cryptoCurrency) - ) { - activeList.add(TokenActionsState.ActionState.Swap(true)) - } else { - disabledList.add(TokenActionsState.ActionState.Swap(false)) + if (userWallet.isMultiCurrency) { + if (marketCryptoCurrencyRepository.isExchangeable(userWallet.walletId, cryptoCurrency)) { + activeList.add(TokenActionsState.ActionState.Swap(true)) + } else { + disabledList.add(TokenActionsState.ActionState.Swap(false)) + } } // buy @@ -164,11 +157,13 @@ class GetCryptoCurrencyActionsUseCase( return activeList + disabledList } - private fun isSendDisabled(cryptoCurrencyStatus: CryptoCurrencyStatus, coinStatus: CryptoCurrencyStatus?): Boolean = - cryptoCurrencyStatus.value.amount.isNullOrZero() || - coinStatus?.value?.amount.isNullOrZero() || - currenciesRepository.hasPendingTransactions( - cryptoCurrencyStatus = cryptoCurrencyStatus, - coinStatus = coinStatus, - ) + private fun isSendDisabled( + cryptoCurrencyStatus: CryptoCurrencyStatus, + coinStatus: CryptoCurrencyStatus?, + ): Boolean = cryptoCurrencyStatus.value.amount.isNullOrZero() || + coinStatus?.value?.amount.isNullOrZero() || + currenciesRepository.hasPendingTransactions( + cryptoCurrencyStatus = cryptoCurrencyStatus, + coinStatus = coinStatus, + ) } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt index 0ff4f92ae7..9fe2ba124a 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCurrencyWarningsUseCase.kt @@ -1,24 +1,34 @@ package com.tangem.domain.tokens +import com.tangem.domain.settings.ShouldShowSwapPromoTokenUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.swap.domain.api.SwapRepository +import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runCatching +import com.tangem.utils.isNullOrZero import kotlinx.coroutines.flow.* import java.math.BigDecimal +@Suppress("LongParameterList") class GetCurrencyWarningsUseCase( private val walletManagersFacade: WalletManagersFacade, private val currenciesRepository: CurrenciesRepository, private val quotesRepository: QuotesRepository, private val networksRepository: NetworksRepository, + private val swapRepository: SwapRepository, + private val marketCryptoCurrencyRepository: MarketCryptoCurrencyRepository, + private val showSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -29,9 +39,15 @@ class GetCurrencyWarningsUseCase( isSingleWalletWithTokens: Boolean, ): Flow> { val currency = currencyStatus.currency + val operations = CurrenciesStatusesOperations( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + userWalletId = userWalletId, + ) return combine( getCoinRelatedWarnings( - userWalletId = userWalletId, + operations = operations, networkId = currency.network.id, currencyId = currency.id, derivationPath = derivationPath, @@ -39,10 +55,14 @@ class GetCurrencyWarningsUseCase( ), flowOf(walletManagersFacade.getRentInfo(userWalletId, currency.network)), flowOf(walletManagersFacade.getExistentialDeposit(userWalletId, currency.network)), - flowOf(getNetworkUnavailableWarning(currencyStatus)), - flowOf(getNetworkNoAccountWarning(currencyStatus)), - ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeNetworkUnavailable, maybeNetworkNoAccount -> + getSwapPromoNotificationWarning( + operations = operations, + userWalletId = userWalletId, + currencyStatus = currencyStatus, + ).conflate(), + ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeSwapPromo -> setOfNotNull( + maybeSwapPromo, maybeRentWarning, maybeEdWarning?.let { CryptoCurrencyWarning.ExistentialDeposit( @@ -51,27 +71,77 @@ class GetCurrencyWarningsUseCase( ) }, *coinRelatedWarnings.toTypedArray(), - maybeNetworkUnavailable, - maybeNetworkNoAccount, + getNetworkUnavailableWarning(currencyStatus), + getNetworkNoAccountWarning(currencyStatus), ) }.flowOn(dispatchers.io) } + private suspend fun getSwapPromoNotificationWarning( + operations: CurrenciesStatusesOperations, + userWalletId: UserWalletId, + currencyStatus: CryptoCurrencyStatus, + ): Flow { + val currency = currencyStatus.currency + val cryptoStatuses = operations.getCurrenciesStatusesSync() + return combine( + showSwapPromoTokenUseCase().conflate(), + flowOf(marketCryptoCurrencyRepository.isExchangeable(userWalletId, currency)).conflate(), + ) { shouldShowSwapPromo, isExchangeable -> + if (shouldShowSwapPromo && isExchangeable && currencyStatus.value !is CryptoCurrencyStatus.Unreachable) { + cryptoStatuses.fold( + ifLeft = { null }, + ifRight = { cryptoCurrencyStatuses -> + val pairs = runCatching(dispatchers.io) { + swapRepository.getPairsOnly( + LeastTokenInfo( + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = currency.network.backendId, + ), + cryptoCurrencyStatuses.map { it.currency }, + ) + }.getOrNull()?.pairs ?: emptyList() + + val filteredCurrencies = cryptoCurrencyStatuses.filterNot { + it.currency.id == currency.id + } + val currencyPairs = pairs.filter { + it.from.network == currency.network.backendId || + it.to.network == currency.network.backendId + } + val showPromo = currencyPairs.any { pair -> + val availablePair = if (currencyStatus.value.amount.isNullOrZero()) { + filteredCurrencies.filterNot { it.value.amount.isNullOrZero() } + } else { + filteredCurrencies + } + availablePair + .any { + it.currency.network.backendId == pair.to.network || + it.currency.network.backendId == pair.from.network + } + } + if (showPromo) { + CryptoCurrencyWarning.SwapPromo + } else { + null + } + }, + ) + } else { + null + } + } + } + @Suppress("LongParameterList") private suspend fun getCoinRelatedWarnings( - userWalletId: UserWalletId, + operations: CurrenciesStatusesOperations, networkId: Network.ID, currencyId: CryptoCurrency.ID, derivationPath: Network.DerivationPath, isSingleWalletWithTokens: Boolean, ): Flow> { - val operations = CurrenciesStatusesOperations( - currenciesRepository = currenciesRepository, - quotesRepository = quotesRepository, - networksRepository = networksRepository, - userWalletId = userWalletId, - ) - val currencyFlow = if (isSingleWalletWithTokens) { operations.getCurrencyStatusSingleWalletWithTokensFlow(currencyId) } else { diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/GetFeeError.kt index e6669368f9..f2e3f7f60f 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/GetFeeError.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/GetFeeError.kt @@ -2,6 +2,5 @@ package com.tangem.domain.transaction.error sealed class GetFeeError { data class DataError(val cause: Throwable?) : GetFeeError() - object UnknownError : GetFeeError() } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/EstimateFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/EstimateFeeUseCase.kt new file mode 100644 index 0000000000..a33088d612 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/EstimateFeeUseCase.kt @@ -0,0 +1,64 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.math.BigDecimal + +/** + * Use case to estimate transaction fee + */ +class EstimateFeeUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val dispatcher: CoroutineDispatcherProvider, +) { + suspend operator fun invoke( + amount: BigDecimal, + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Flow> { + return flow { + val result = walletManagersFacade.estimateFee( + amount = convertCryptoCurrencyToAmount(cryptoCurrency, amount), + userWalletId = userWalletId, + network = cryptoCurrency.network, + ) + + val maybeFee = when (result) { + is Result.Success -> result.data.right() + is Result.Failure -> GetFeeError.DataError(result.error).left() + null -> GetFeeError.UnknownError.left() + } + emit(maybeFee) + }.flowOn(dispatcher.io) + } + + private fun convertCryptoCurrencyToAmount(cryptoCurrency: CryptoCurrency, amount: BigDecimal) = Amount( + currencySymbol = cryptoCurrency.symbol, + value = amount, + decimals = cryptoCurrency.decimals, + type = when (cryptoCurrency) { + is CryptoCurrency.Coin -> AmountType.Coin + is CryptoCurrency.Token -> AmountType.Token( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) + }, + ) +} \ No newline at end of file diff --git a/features/onboarding/src/main/java/com/tangem/feature/onboarding/data/DefaultMnemonicRepository.kt b/features/onboarding/src/main/java/com/tangem/feature/onboarding/data/DefaultMnemonicRepository.kt index 9cb238b99e..71ac3393b7 100644 --- a/features/onboarding/src/main/java/com/tangem/feature/onboarding/data/DefaultMnemonicRepository.kt +++ b/features/onboarding/src/main/java/com/tangem/feature/onboarding/data/DefaultMnemonicRepository.kt @@ -8,7 +8,7 @@ import com.tangem.crypto.bip39.Wordlist /** [REDACTED_AUTHOR] */ -internal class DefaultMnemonicRepository( +class DefaultMnemonicRepository( private val bip39Wordlist: Wordlist, ) : MnemonicRepository { diff --git a/features/swap/data/build.gradle.kts b/features/swap/data/build.gradle.kts index 53dddfc5a9..12693598d2 100644 --- a/features/swap/data/build.gradle.kts +++ b/features/swap/data/build.gradle.kts @@ -15,6 +15,8 @@ dependencies { implementation(projects.core.datasource) implementation(projects.core.utils) implementation(projects.features.swap.domain) + implementation(projects.features.swap.domain.models) + implementation(projects.features.swap.domain.api) /** Network */ implementation(deps.retrofit) @@ -35,6 +37,9 @@ dependencies { /** Tangem SDKs */ implementation(deps.tangem.blockchain) + /** Others */ + implementation(deps.timber) + /** DI */ implementation(deps.hilt.android) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt similarity index 60% rename from features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt rename to features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index 3b4c45e77e..fa5e911138 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -1,53 +1,58 @@ package com.tangem.feature.swap import arrow.core.Either +import arrow.core.left import arrow.core.raise.catch import arrow.core.raise.either -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.Approver -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.Token +import arrow.core.right +import com.squareup.moshi.Moshi +import com.tangem.blockchain.common.* import com.tangem.blockchain.extensions.Result import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.request.PairsRequestBody +import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails import com.tangem.datasource.api.express.models.response.SwapPair import com.tangem.datasource.api.express.models.response.SwapPairsWithProviders -import com.tangem.datasource.api.oneinch.OneInchApi -import com.tangem.datasource.api.oneinch.OneInchApiFactory +import com.tangem.datasource.api.express.models.response.TxDetails import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.crypto.DataSignatureVerifier import com.tangem.domain.common.extensions.fromNetworkId import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.converters.* -import com.tangem.feature.swap.domain.SwapRepository +import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.DataError +import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.createFromAmountWithOffset -import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel import com.tangem.feature.swap.domain.models.domain.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.async import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.IOException import java.math.BigDecimal +import java.util.UUID import javax.inject.Inject import com.tangem.datasource.api.express.models.request.LeastTokenInfo as NetworkLeastTokenInfo -@Suppress("LongParameterList") -internal class SwapRepositoryImpl @Inject constructor( +@Suppress("LongParameterList", "LargeClass") +internal class DefaultSwapRepository @Inject constructor( private val tangemTechApi: TangemTechApi, private val tangemExpressApi: TangemExpressApi, - private val oneInchApiFactory: OneInchApiFactory, private val coroutineDispatcher: CoroutineDispatcherProvider, - private val configManager: ConfigManager, private val walletManagersFacade: WalletManagersFacade, private val walletsStateHolder: WalletsStateHolder, private val errorsDataConverter: ErrorsDataConverter, + private val dataSignatureVerifier: DataSignatureVerifier, + moshi: Moshi, ) : SwapRepository { private val tokensConverter = TokensConverter() @@ -56,55 +61,114 @@ internal class SwapRepositoryImpl @Inject constructor( private val swapPairInfoConverter = SwapPairInfoConverter() private val cryptoCurrencyFactory = CryptoCurrencyFactory() private val exchangeStatusConverter = ExchangeStatusConverter() + private val txDetailsMoshiAdapter = moshi.adapter(TxDetails::class.java) override suspend fun getPairs( initialCurrency: LeastTokenInfo, currencyList: List, - ): List { + ): PairsWithProviders { return withContext(coroutineDispatcher.io) { - val initial = NetworkLeastTokenInfo( - contractAddress = initialCurrency.contractAddress, - network = initialCurrency.network, - ) - val currenciesList = currencyList.map { leastTokenInfoConverter.convert(it) } - - val pairs = async { - getPairsInternal( - from = arrayListOf(initial), - to = currenciesList, + try { + val initial = NetworkLeastTokenInfo( + contractAddress = initialCurrency.contractAddress, + network = initialCurrency.network, ) - } + val currenciesList = currencyList.map { leastTokenInfoConverter.convert(it) } - val reversedPairs = async { - getPairsInternal( - from = currenciesList, - to = arrayListOf(initial), + val pairsDeferred = async { + getPairsInternal( + from = arrayListOf(initial), + to = currenciesList, + ) + } + + val reversedPairsDeferred = async { + getPairsInternal( + from = currenciesList, + to = arrayListOf(initial), + ) + } + + val pairs = pairsDeferred.await().getOrThrow() + val reversedPairs = reversedPairsDeferred.await().getOrThrow() + + val allPairs = pairs + reversedPairs + + val providers = tangemExpressApi.getProviders().getOrThrow() + + return@withContext swapPairInfoConverter.convert( + SwapPairsWithProviders( + swapPair = allPairs, + providers = providers, + ), ) + } catch (exception: Exception) { + if (exception is ApiResponseError.HttpException) { + throw ExpressException(errorsDataConverter.convert(exception.errorBody ?: "")) + } else { + throw exception + } } + } + } - val allPairs = pairs.await() + reversedPairs.await() + override suspend fun getPairsOnly( + initialCurrency: LeastTokenInfo, + currencyList: List, + ): PairsWithProviders { + return withContext(coroutineDispatcher.io) { + try { + val initial = NetworkLeastTokenInfo( + contractAddress = initialCurrency.contractAddress, + network = initialCurrency.network, + ) + val currenciesList = currencyList.map { leastTokenInfoConverter.convert(it) } - val providers = tangemExpressApi.getProviders().getOrThrow() + val pairsDeferred = async { + getPairsInternal( + from = arrayListOf(initial), + to = currenciesList, + ) + } - return@withContext swapPairInfoConverter.convert( - SwapPairsWithProviders( - swapPair = allPairs, - providers = providers, - ), - ) + val reversedPairsDeferred = async { + getPairsInternal( + from = currenciesList, + to = arrayListOf(initial), + ) + } + + val pairs = pairsDeferred.await().getOrThrow() + val reversedPairs = reversedPairsDeferred.await().getOrThrow() + + val allPairs = pairs + reversedPairs + + return@withContext swapPairInfoConverter.convert( + SwapPairsWithProviders( + swapPair = allPairs, + providers = emptyList(), + ), + ) + } catch (exception: Exception) { + if (exception is ApiResponseError.HttpException) { + throw ExpressException(errorsDataConverter.convert(exception.errorBody ?: "")) + } else { + throw exception + } + } } } private suspend fun getPairsInternal( from: List, to: List, - ): List { + ): ApiResponse> { return tangemExpressApi.getPairs( PairsRequestBody( from = from, to = to, ), - ).getOrThrow() + ) } override suspend fun getExchangeStatus(txId: String): Either { @@ -171,7 +235,7 @@ internal class SwapRepositoryImpl @Inject constructor( toDecimals: Int, providerId: String, rateType: RateType, - ): AggregatedSwapDataModel { + ): Either { return withContext(coroutineDispatcher.io) { try { val response = tangemExpressApi.getExchangeQuote( @@ -185,24 +249,16 @@ internal class SwapRepositoryImpl @Inject constructor( providerId = providerId, rateType = rateType.name.lowercase(), ).getOrThrow() - AggregatedSwapDataModel( - dataModel = QuoteModel( - toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals), - allowanceContract = response.allowanceContract, - ), - ) + QuoteModel( + toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals), + allowanceContract = response.allowanceContract, + ).right() } catch (ex: Exception) { - AggregatedSwapDataModel(null, getDataError(ex)) + getDataError(ex).left() } } } - override suspend fun addressForTrust(networkId: String): String { - return withContext(coroutineDispatcher.io) { - getOneInchApi(networkId).approveSpender().address - } - } - override suspend fun getExchangeData( fromContractAddress: String, fromNetwork: String, @@ -214,9 +270,10 @@ internal class SwapRepositoryImpl @Inject constructor( providerId: String, rateType: RateType, toAddress: String, - ): AggregatedSwapDataModel { + ): Either { return withContext(coroutineDispatcher.io) { try { + val requestId = UUID.randomUUID().toString() val response = tangemExpressApi.getExchangeData( fromContractAddress = fromContractAddress, fromNetwork = fromNetwork, @@ -228,18 +285,43 @@ internal class SwapRepositoryImpl @Inject constructor( providerId = providerId, rateType = rateType.name.lowercase(), toAddress = toAddress, + requestId = requestId, ).getOrThrow() - AggregatedSwapDataModel( - dataModel = expressDataConverter.convert(response), - ) + if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) { + val txDetails = parseTxDetails(response.txDetailsJson) + ?: return@withContext DataError.UnknownError.left() + if (txDetails.requestId != requestId) { + return@withContext DataError.InvalidRequestIdError().left() + } + if (!toAddress.equals(txDetails.payoutAddress, ignoreCase = true)) { + return@withContext DataError.InvalidPayoutAddressError().left() + } + expressDataConverter.convert( + ExchangeDataResponseWithTxDetails( + dataResponse = response, + txDetails = txDetails, + ), + ).right() + } else { + DataError.InvalidSignatureError().left() + } } catch (ex: Exception) { - AggregatedSwapDataModel(null, getDataError(ex)) + getDataError(ex).left() } } } - override fun getTangemFee(): Double { - return configManager.config.swapReferrerAccount?.fee?.toDoubleOrNull() ?: 0.0 + override suspend fun getExistentialDeposit(userWalletId: UserWalletId, network: Network): BigDecimal? { + return walletManagersFacade.getExistentialDeposit(userWalletId, network) + } + + private fun parseTxDetails(txDetailsJson: String): TxDetails? { + return try { + txDetailsMoshiAdapter.fromJson(txDetailsJson) + } catch (e: IOException) { + Timber.e(e, "error parsing txDetailsJson") + null + } } override suspend fun getAllowance( @@ -290,27 +372,27 @@ internal class SwapRepositoryImpl @Inject constructor( return (walletManager as? Approver)?.getApproveData( spenderAddress, - amount?.let { convertToAmount(it, currency, blockchain) }, + amount?.let { convertToAmount(it, currency) }, ) ?: error("Cannot cast to Approver") } - private fun convertToAmount(amount: BigDecimal, currency: CryptoCurrency, blockchain: Blockchain): Amount { - return when (currency) { - is CryptoCurrency.Token -> { - Amount(value = amount, blockchain = blockchain) - } - is CryptoCurrency.Coin -> { - Amount( - currencySymbol = currency.symbol, - value = amount, - decimals = currency.decimals, + private fun convertToAmount(amount: BigDecimal, currency: CryptoCurrency): Amount { + return Amount( + currencySymbol = currency.symbol, + value = amount, + decimals = currency.decimals, + type = if (currency is CryptoCurrency.Token) { + AmountType.Token( + Token( + symbol = currency.symbol, + contractAddress = currency.contractAddress, + decimals = currency.decimals, + ), ) - } - } - } - - private fun getOneInchApi(networkId: String): OneInchApi { - return oneInchApiFactory.getApi(networkId) + } else { + AmountType.Coin + }, + ) } override fun getNativeTokenForNetwork(networkId: String): CryptoCurrency { diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index 97b9a1e2b2..f617305f17 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -4,9 +4,11 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList import com.tangem.datasource.local.preferences.utils.getObjectListSync +import com.tangem.datasource.local.preferences.utils.getObjectMap import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.domain.SwapTransactionRepository +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedLastSwappedCryptoCurrency import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel @@ -24,11 +26,11 @@ class DefaultSwapTransactionRepository( toCryptoCurrencyId: CryptoCurrency.ID, transaction: SavedSwapTransactionModel, ) { + transaction.status?.let { storeTransactionState(transaction.txId, it) } appPreferencesStore.editData { mutablePreferences -> val savedTransactions: List? = mutablePreferences.getObjectList( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, ) - val tokenTransactions = savedTransactions ?.firstOrNull { it.checkId( @@ -62,14 +64,17 @@ class DefaultSwapTransactionRepository( } } - override fun getTransactions( + override suspend fun getTransactions( userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, ): Flow?> { + val txStatuses = appPreferencesStore.getObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) return appPreferencesStore.getObjectList( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, ).map { savedTransactions -> - savedTransactions + val currencyTxs = savedTransactions ?.filter { it.userWalletId == userWalletId.stringValue && ( @@ -77,6 +82,14 @@ class DefaultSwapTransactionRepository( it.fromCryptoCurrencyId == cryptoCurrencyId.value ) } + + currencyTxs?.map { currencyTx -> + currencyTx.copy( + transactions = currencyTx.transactions.map { tx -> + tx.copy(status = txStatuses[tx.txId]) + }, + ) + } } } @@ -86,6 +99,7 @@ class DefaultSwapTransactionRepository( toCryptoCurrencyId: CryptoCurrency.ID, txId: String, ) { + clearTransactionsStatuses(txId = txId) appPreferencesStore.editData { mutablePreferences -> val savedList: List? = mutablePreferences.getObjectList( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, @@ -130,6 +144,22 @@ class DefaultSwapTransactionRepository( } } + override suspend fun storeTransactionState(txId: String, status: ExchangeStatusModel) { + appPreferencesStore.editData { mutablePreferences -> + val savedMap = mutablePreferences.getObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) + + val updatesMap = savedMap?.toMutableMap() ?: mutableMapOf() + updatesMap[txId] = status + + mutablePreferences.setObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + value = updatesMap, + ) + } + } + override suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? { val lastSwappedCurrencies = appPreferencesStore.getObjectListSync( key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, @@ -194,4 +224,22 @@ class DefaultSwapTransactionRepository( }, ) } + + private suspend fun clearTransactionsStatuses(txId: String) { + appPreferencesStore.editData { mutablePreferences -> + val savedList = mutablePreferences.getObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + ) + val editedList = savedList?.filterNot { it.key == txId } + + if (editedList.isNullOrEmpty()) { + mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY) + } else { + mutablePreferences.setObjectMap( + key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, + value = editedList, + ) + } + } + } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt index 2068fc982a..fa28cf44f2 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap.converters import com.squareup.moshi.JsonAdapter +import com.tangem.datasource.api.express.models.response.ExpressError import com.tangem.datasource.api.express.models.response.ExpressErrorResponse import com.tangem.feature.swap.domain.models.DataError import com.tangem.feature.swap.domain.models.createFromAmountWithOffset @@ -21,28 +22,47 @@ internal class ErrorsDataConverter( 2220 -> DataError.ExchangeProviderNotActiveError(code = error.code) 2230 -> DataError.ExchangeProviderNotAvailableError(code = error.code) 2240 -> DataError.ExchangeNotPossibleError(code = error.code) - 2250 -> DataError.ExchangeTooSmallAmountError( - code = error.code, - amount = createFromAmountWithOffset( - requireNotNull(error.value?.minAmount), - requireNotNull(error.value?.decimals), - ), - ) - 2260 -> DataError.ExchangeNotEnoughAllowanceError( - code = error.code, - currentAllowance = requireNotNull(error.value?.currentAllowance), - ) + 2250 -> tryParseExchangeTooSmallAmountError(error = error) + 2260 -> tryParseExchangeNotEnoughAllowanceError(error = error) 2270 -> DataError.ExchangeNotEnoughBalanceError(code = error.code) 2280 -> DataError.ExchangeInvalidAddressError(code = error.code) - 2290 -> DataError.ExchangeInvalidFromDecimalsError( - code = error.code, - receivedFromDecimals = requireNotNull(error.value?.receivedFromDecimals), - expressFromDecimals = requireNotNull(error.value?.expressFromDecimals), - ) - else -> DataError.UnknownError + 2290 -> tryParseExchangeInvalidFromDecimalsError(error = error) + else -> DataError.UnknownErrorWithCode(error.code) } } catch (e: Exception) { return DataError.UnknownError } } + + private fun tryParseExchangeTooSmallAmountError(error: ExpressError): DataError { + val minAmount = error.value?.minAmount ?: return DataError.UnknownErrorWithCode(error.code) + val decimals = error.value?.decimals ?: return DataError.UnknownErrorWithCode(error.code) + + return DataError.ExchangeTooSmallAmountError( + code = error.code, + amount = createFromAmountWithOffset(minAmount, decimals), + ) + } + + private fun tryParseExchangeNotEnoughAllowanceError(error: ExpressError): DataError { + val currentAllowance = error.value?.currentAllowance ?: return DataError.UnknownErrorWithCode(error.code) + + return DataError.ExchangeNotEnoughAllowanceError( + code = error.code, + currentAllowance = currentAllowance, + ) + } + + private fun tryParseExchangeInvalidFromDecimalsError(error: ExpressError): DataError { + val receivedFromDecimals = error.value?.receivedFromDecimals ?: return DataError.UnknownErrorWithCode( + code = error.code, + ) + val expressFromDecimals = error.value?.expressFromDecimals ?: return DataError.UnknownErrorWithCode(error.code) + + return DataError.ExchangeInvalidFromDecimalsError( + code = error.code, + receivedFromDecimals = receivedFromDecimals, + expressFromDecimals = expressFromDecimals, + ) + } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExchangeStatusConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExchangeStatusConverter.kt index f5901e8c69..30ea8ec4cd 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExchangeStatusConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExchangeStatusConverter.kt @@ -13,7 +13,7 @@ internal class ExchangeStatusConverter : Converter { +internal class ExpressDataConverter : Converter { - override fun convert(value: ExchangeDataResponse): SwapDataModel { + override fun convert(value: ExchangeDataResponseWithTxDetails): SwapDataModel { + val data = value.dataResponse return SwapDataModel( - toTokenAmount = createFromAmountWithOffset(value.toAmount, value.toDecimals), - transaction = convertTransaction(value), + toTokenAmount = createFromAmountWithOffset(data.toAmount, data.toDecimals), + transaction = convertTransaction(value.txDetails, data), ) } - private fun convertTransaction(transactionDto: ExchangeDataResponse): ExpressTransactionModel { + private fun convertTransaction( + transactionDto: TxDetails, + dataResponse: ExchangeDataResponse, + ): ExpressTransactionModel { return if (transactionDto.txType == TxType.SWAP) { ExpressTransactionModel.DEX( - fromAmount = createFromAmountWithOffset(transactionDto.fromAmount, transactionDto.fromDecimals), - toAmount = createFromAmountWithOffset(transactionDto.toAmount, transactionDto.toDecimals), - txId = transactionDto.txId, + fromAmount = createFromAmountWithOffset(dataResponse.fromAmount, dataResponse.fromDecimals), + toAmount = createFromAmountWithOffset(dataResponse.toAmount, dataResponse.toDecimals), + txId = dataResponse.txId, txTo = transactionDto.txTo, txFrom = requireNotNull(transactionDto.txFrom), txData = requireNotNull(transactionDto.txData), ) } else { ExpressTransactionModel.CEX( - fromAmount = createFromAmountWithOffset(transactionDto.fromAmount, transactionDto.fromDecimals), - toAmount = createFromAmountWithOffset(transactionDto.toAmount, transactionDto.toDecimals), - txId = transactionDto.txId, + fromAmount = createFromAmountWithOffset(dataResponse.fromAmount, dataResponse.fromDecimals), + toAmount = createFromAmountWithOffset(dataResponse.toAmount, dataResponse.toDecimals), + txId = dataResponse.txId, txTo = transactionDto.txTo, externalTxId = requireNotNull(transactionDto.externalTxId), externalTxUrl = requireNotNull(transactionDto.externalTxUrl), + txExtraIdName = transactionDto.txExtraIdName, + txExtraId = transactionDto.txExtraId, ) } } diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt index ff86a7e049..5bb5079f95 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt @@ -2,18 +2,20 @@ package com.tangem.feature.swap.converters import com.tangem.datasource.api.express.models.response.* import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo +import com.tangem.feature.swap.domain.models.domain.PairsWithProviders +import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.utils.converter.Converter import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType as ExchangeProviderTypeDomain import com.tangem.feature.swap.domain.models.domain.SwapPairLeast as SwapPairDomain import com.tangem.feature.swap.domain.models.domain.SwapProvider as SwapPairProviderDomain -class SwapPairInfoConverter : Converter> { +class SwapPairInfoConverter : Converter { private val rateTypeConverter = RateTypeConverter() - override fun convert(value: SwapPairsWithProviders): List { + override fun convert(value: SwapPairsWithProviders): PairsWithProviders { val providersAdditionalMap = value.providers.associateBy { it.id } - return value.swapPair.map { pair -> + val pairs = value.swapPair.map { pair -> SwapPairDomain( from = LeastTokenInfo( contractAddress = pair.from.contractAddress, @@ -28,6 +30,22 @@ class SwapPairInfoConverter : Converter): List + suspend fun getPairs(initialCurrency: LeastTokenInfo, currencyList: List): PairsWithProviders + + /** Express getPairs request variant without providers request */ + suspend fun getPairsOnly(initialCurrency: LeastTokenInfo, currencyList: List): PairsWithProviders suspend fun getRates(currencyId: String, tokenIds: List): Map @@ -31,20 +35,7 @@ interface SwapRepository { toDecimals: Int, providerId: String, rateType: RateType, - ): AggregatedSwapDataModel - - /** - * Returns address of 1inch router that must be trusted - * - * @return address - */ - suspend fun addressForTrust(networkId: String): String - - /** - * Returns a tangem fee for swap in percents - * Example: 0.35% - */ - fun getTangemFee(): Double + ): Either @Suppress("LongParameterList") @Throws(IllegalStateException::class) @@ -80,7 +71,9 @@ interface SwapRepository { providerId: String, rateType: RateType, toAddress: String, - ): AggregatedSwapDataModel + ): Either fun getNativeTokenForNetwork(networkId: String): CryptoCurrency + + suspend fun getExistentialDeposit(userWalletId: UserWalletId, network: Network): BigDecimal? } \ No newline at end of file diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index f3841b400d..5fa5dfec86 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -28,6 +28,9 @@ dependencies { implementation(projects.domain.demo) implementation(projects.domain.card) implementation(projects.domain.appCurrency.models) + implementation(projects.domain.txhistory.models) + implementation(projects.features.swap.domain.api) + implementation(projects.features.swap.domain.models) /** Core modules */ implementation(projects.core.utils) @@ -37,7 +40,6 @@ dependencies { implementation(projects.features.wallet.api) /** Other Libraries **/ - implementation(deps.kotlin.serialization) implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) implementation(deps.timber) diff --git a/features/swap/domain/models/.gitignore b/features/swap/domain/models/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/swap/domain/models/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/swap/domain/models/build.gradle.kts b/features/swap/domain/models/build.gradle.kts new file mode 100644 index 0000000000..96958add9b --- /dev/null +++ b/features/swap/domain/models/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.feature.swap.domain.models" +} + +dependencies { + /** Domain */ + implementation(projects.domain.tokens.models) + + /** Core modules */ + implementation(projects.core.utils) + + /** Other Libraries **/ + implementation(deps.kotlin.serialization) + implementation(deps.arrow.core) + +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt similarity index 78% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt index 2c3a3e6af6..0da33b0bcf 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt @@ -30,6 +30,14 @@ sealed class DataError { val expressFromDecimals: Int, ) : DataError() + data class UnknownErrorWithCode(override val code: Int) : DataError() + + data class InvalidSignatureError(override val code: Int = 990) : DataError() + + data class InvalidRequestIdError(override val code: Int = 991) : DataError() + + data class InvalidPayoutAddressError(override val code: Int = 992) : DataError() + object UnknownError : DataError() { override val code: Int = -1 } diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt new file mode 100644 index 0000000000..cc416b82ca --- /dev/null +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ExpressException.kt @@ -0,0 +1,3 @@ +package com.tangem.feature.swap.domain.models + +class ExpressException(val dataError: DataError) : Exception() \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/SwapAmount.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/Currency.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/Currency.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/Currency.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/Currency.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt similarity index 86% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt index dc5c5738be..1e3727b208 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt @@ -4,7 +4,7 @@ data class ExchangeStatusModel( val providerId: String, val status: ExchangeStatus? = null, val txId: String? = null, - val txUrl: String? = null, + val txExternalUrl: String? = null, ) enum class ExchangeStatus { @@ -17,4 +17,5 @@ enum class ExchangeStatus { Sending, Finished, Refunded, + Cancelled, } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt similarity index 92% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt index 89ab7c6754..a1c0981569 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt @@ -25,5 +25,7 @@ sealed class ExpressTransactionModel { override val txTo: String, val externalTxId: String, val externalTxUrl: String, + val txExtraIdName: String?, + val txExtraId: String?, ) : ExpressTransactionModel() } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt new file mode 100644 index 0000000000..c642d3f52e --- /dev/null +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PairsWithProviders.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.domain.models.domain + +data class PairsWithProviders( + val pairs: List, + val allProviders: List, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt similarity index 95% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt index 53cfae4ded..ca44dc046e 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt @@ -14,6 +14,7 @@ data class PreparedSwapConfigState( val isAllowedToSpend: Boolean, val isBalanceEnough: Boolean, val isFeeEnough: Boolean, + val hasOutgoingTransaction: Boolean, val includeFeeInAmount: IncludeFeeInAmount, ) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt similarity index 95% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index 76fa9a8238..9df61116fa 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -32,6 +32,8 @@ data class SwapProvider( val name: String, val type: ExchangeProviderType, val imageLarge: String, + val termsOfUse: String?, + val privacyPolicy: String?, ) enum class ExchangeProviderType { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/TransactionModel.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/TransactionModel.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/TransactionModel.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/TransactionModel.kt diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/Warning.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/Warning.kt new file mode 100644 index 0000000000..b52f4c5586 --- /dev/null +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/Warning.kt @@ -0,0 +1,8 @@ +package com.tangem.feature.swap.domain.models.domain + +import java.math.BigDecimal + +sealed class Warning { + + data class ExistentialDepositWarning(val existentialDeposit: BigDecimal) : Warning() +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt similarity index 100% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt similarity index 81% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index d031891ba3..a7c1ff4892 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -6,6 +6,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.Warning import java.math.BigDecimal sealed interface SwapState { @@ -13,18 +14,19 @@ sealed interface SwapState { data class QuotesLoadedState( val fromTokenInfo: TokenSwapInfo, val toTokenInfo: TokenSwapInfo, - val priceImpact: Float, + val priceImpact: PriceImpact, val networkCurrency: String, val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState( isAllowedToSpend = false, isBalanceEnough = false, isFeeEnough = false, + hasOutgoingTransaction = false, includeFeeInAmount = IncludeFeeInAmount.Excluded, ), val permissionState: PermissionDataState = PermissionDataState.Empty, val swapDataModel: SwapDataModel? = null, val txFee: TxFeeState, - val tangemFee: Double, + val warnings: List = emptyList(), ) : SwapState data class EmptyAmountState( @@ -36,9 +38,24 @@ sealed interface SwapState { data class SwapError( val fromTokenInfo: TokenSwapInfo, val error: DataError, + val includeFeeInAmount: IncludeFeeInAmount, ) : SwapState } +sealed class PriceImpact { + + abstract val value: Float + + fun getIntPercentValue() = (value * HUNDRED_PERCENTS).toInt() + data class Empty(override val value: Float = 0f) : PriceImpact() + data class ValueWithNotify(override val value: Float) : PriceImpact() + data class Value(override val value: Float) : PriceImpact() + + companion object { + private const val HUNDRED_PERCENTS = 100 + } +} + sealed class PermissionDataState { data class PermissionReadyForRequest( diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataState.kt new file mode 100644 index 0000000000..8d17d05808 --- /dev/null +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataState.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.swap.domain.models.ui + +import com.tangem.feature.swap.domain.models.domain.Currency + +data class TokensDataState( + val preselectTokens: PreselectTokens, + val foundTokensState: FoundTokensState, +) + +data class FoundTokensState( + val tokensInWallet: List, + val loadedTokens: List, +) + +data class PreselectTokens( + val fromToken: Currency, + val toToken: Currency, +) + +data class TokenWithBalance( + val token: Currency, + val tokenBalanceData: TokenBalanceData? = null, +) + +data class TokenBalanceData( + val amount: String?, + val amountEquivalent: String?, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt similarity index 74% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt index 1d29633fec..1aa9ba51d1 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -1,16 +1,19 @@ package com.tangem.feature.swap.domain.models.ui import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo +import com.tangem.feature.swap.domain.models.domain.SwapProvider data class TokensDataStateExpress( val fromGroup: CurrenciesGroup, val toGroup: CurrenciesGroup, + val allProviders: List, ) { companion object { val EMPTY = TokensDataStateExpress( - fromGroup = CurrenciesGroup(emptyList(), emptyList()), - toGroup = CurrenciesGroup(emptyList(), emptyList()), + fromGroup = CurrenciesGroup(emptyList(), emptyList(), false), + toGroup = CurrenciesGroup(emptyList(), emptyList(), false), + allProviders = emptyList(), ) } } @@ -18,4 +21,5 @@ data class TokensDataStateExpress( data class CurrenciesGroup( val available: List, val unavailable: List, + val afterSearch: Boolean, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt similarity index 74% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt rename to features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt index a593888610..14259bc672 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt @@ -1,11 +1,15 @@ package com.tangem.feature.swap.domain.models.ui +import java.math.BigDecimal + sealed class TxState { data class TxSent( val fromAmount: String? = null, + val fromAmountValue: BigDecimal? = null, val toAmount: String? = null, - val txAddress: String, + val toAmountValue: BigDecimal? = null, + val txHash: String, val txExternalUrl: String? = null, val timestamp: Long, ) : TxState() diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt index 8883e0f1a3..ceab0eb503 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt @@ -11,5 +11,5 @@ interface BlockchainInteractor { */ fun getBlockchainInfo(networkId: String): NetworkInfo - fun getExplorerTransactionLink(networkId: String, txAddress: String): String + fun getExplorerTransactionLink(networkId: String, txHash: String): String } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt index 836f193fe3..ea6207fece 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt @@ -18,7 +18,7 @@ internal class DefaultBlockchainInteractor @Inject constructor( } } - override fun getExplorerTransactionLink(networkId: String, txAddress: String): String { - return transactionManager.getExplorerTransactionLink(networkId, txAddress) + override fun getExplorerTransactionLink(networkId: String, txHash: String): String { + return transactionManager.getExplorerTransactionLink(networkId, txHash) } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index d36610f673..d3ec1058b9 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -2,18 +2,18 @@ package com.tangem.feature.swap.domain import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount +import com.tangem.feature.swap.domain.models.domain.PermissionOptions +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* interface SwapInteractor { suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress - fun initDerivationPathAndNetwork(derivationPath: String?, network: Network) - /** * Gives permission to swap, this starts scan card process * @@ -27,7 +27,6 @@ interface SwapInteractor { * Find best quote for given tokens to swap * under the hood calls different methods to receive data, depends on permission for given token * - * @param networkId network for tokens * @param fromToken [Currency] from which want to swap * @param toToken [Currency] that receive after swap * @param amountToSwap amount you want to swap @@ -36,7 +35,6 @@ interface SwapInteractor { */ @Throws(IllegalStateException::class) suspend fun findBestQuote( - networkId: String, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, providers: List, @@ -59,7 +57,6 @@ interface SwapInteractor { @Throws(IllegalStateException::class) suspend fun onSwap( swapProvider: SwapProvider, - networkId: String, swapData: SwapDataModel?, currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, @@ -73,7 +70,6 @@ interface SwapInteractor { selectedFee: FeeType, fromToken: CryptoCurrencyStatus, amountToSwap: String, - networkId: String, ): SwapState.QuotesLoadedState /** diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 5a843b643a..38f55b3561 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain +import arrow.core.Either import arrow.core.getOrElse import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.AmountType @@ -8,20 +9,20 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.tokens.utils.convertToAmount import com.tangem.domain.transaction.error.SendTransactionError -import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter +import com.tangem.feature.swap.domain.models.DataError import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.ui.* @@ -35,6 +36,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.firstOrNull import timber.log.Timber import java.math.BigDecimal +import java.math.BigInteger import java.math.RoundingMode import javax.inject.Inject @@ -54,14 +56,13 @@ internal class SwapInteractorImpl @Inject constructor( private val initialToCurrencyResolver: InitialToCurrencyResolver, ) : SwapInteractor { - private val getFeeUseCase by lazy(LazyThreadSafetyMode.NONE) { - GetFeeUseCase(walletManagersFacade, dispatcher) + private val estimateFeeUseCase by lazy(LazyThreadSafetyMode.NONE) { + EstimateFeeUseCase(walletManagersFacade, dispatcher) } private val swapCurrencyConverter = SwapCurrencyConverter() private val amountFormatter = AmountFormatter() - private var derivationPath: String? = null - private var network: Network? = null + private val hundredPercent = BigInteger("100") override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { val selectedWallet = getSelectedWalletSyncUseCase().fold( @@ -78,15 +79,13 @@ internal class SwapInteractorImpl @Inject constructor( .filter { val currencyFilter = it.currency.network.backendId != currency.network.backendId || it.currency.getContractAddress() != currency.getContractAddress() - val statusFilter = it.value is CryptoCurrencyStatus.Loaded - statusFilter && currencyFilter + val statusFilter = it.value is CryptoCurrencyStatus.Loaded || it.value is CryptoCurrencyStatus.NoAccount + val notCustomTokenFilter = !it.currency.isCustom + statusFilter && currencyFilter && notCustomTokenFilter } if (walletCurrencyStatusesExceptInitial.isEmpty()) { - return TokensDataStateExpress( - fromGroup = CurrenciesGroup(emptyList(), emptyList()), - toGroup = CurrenciesGroup(emptyList(), emptyList()), - ) + return TokensDataStateExpress.EMPTY } val pairsLeast = getPairs( @@ -100,18 +99,19 @@ internal class SwapInteractorImpl @Inject constructor( return TokensDataStateExpress( fromGroup = getToCurrenciesGroup( currency = currency, - leastPairs = pairsLeast, - cryptoCurrenciesList = walletCurrencyStatusesExceptInitial, - tokenInfoForFilter = { it.from }, - tokenInfoForAvailable = { it.to }, - ), - toGroup = getToCurrenciesGroup( - currency = currency, - leastPairs = pairsLeast, + leastPairs = pairsLeast.pairs, cryptoCurrenciesList = walletCurrencyStatusesExceptInitial, tokenInfoForFilter = { it.to }, tokenInfoForAvailable = { it.from }, ), + toGroup = getToCurrenciesGroup( + currency = currency, + leastPairs = pairsLeast.pairs, + cryptoCurrenciesList = walletCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.from }, + tokenInfoForAvailable = { it.to }, + ), + allProviders = pairsLeast.allProviders, ) } @@ -147,6 +147,7 @@ internal class SwapInteractorImpl @Inject constructor( return CurrenciesGroup( available = availableCryptoCurrencies, unavailable = unavailableCryptoCurrencies.map { CryptoCurrencySwapInfo(it, emptyList()) }, + afterSearch = false, ) } @@ -177,18 +178,13 @@ internal class SwapInteractorImpl @Inject constructor( private suspend fun getPairs( initialCurrency: LeastTokenInfo, currenciesList: List, - ): List { + ): PairsWithProviders { return repository.getPairs(initialCurrency, currenciesList) } - @Deprecated("used in old swap mechanism") - override fun initDerivationPathAndNetwork(derivationPath: String?, network: Network) { - this.derivationPath = derivationPath - this.network = network - } - @Deprecated("used in old swap mechanism") override suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): TxState { + val derivationPath = permissionOptions.fromToken.network.derivationPath.value val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) { getApproveData( networkId = networkId, @@ -218,7 +214,7 @@ internal class SwapInteractorImpl @Inject constructor( is SendTxResult.Success -> { allowPermissionsHandler.addAddressToInProgress(permissionOptions.forTokenContractAddress) TxState.TxSent( - txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), + txHash = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), timestamp = System.currentTimeMillis(), ) } @@ -232,7 +228,6 @@ internal class SwapInteractorImpl @Inject constructor( @Deprecated("used in old swap mechanism") override suspend fun findBestQuote( - networkId: String, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, providers: List, @@ -248,7 +243,7 @@ internal class SwapInteractorImpl @Inject constructor( } val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency)) val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) - + val networkId = fromToken.currency.network.backendId when (provider.type) { ExchangeProviderType.DEX -> { manageDex( @@ -269,7 +264,6 @@ internal class SwapInteractorImpl @Inject constructor( provider = provider, amount = amount, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - selectedFee = selectedFee, ) } } @@ -298,17 +292,21 @@ internal class SwapInteractorImpl @Inject constructor( ) val fromTokenAddress = getTokenAddress(fromToken.currency) - val isAllowedToSpend = if (quotes.dataModel != null) { - quotes.dataModel.allowanceContract?.let { - isAllowedToSpend(networkId, fromToken.currency, amount, it) - } ?: true - } else { - false - } + val isAllowedToSpend = quotes.fold( + ifRight = { + it.allowanceContract?.let { + isAllowedToSpend(networkId, fromToken.currency, amount, it) + } ?: true + }, + ifLeft = { false }, + ) if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) - transactionManager.updateWalletManager(networkId, derivationPath) + transactionManager.updateWalletManager( + networkId, + fromToken.currency.network.derivationPath.value, + ) } return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { provider to loadDexSwapData( @@ -342,7 +340,6 @@ internal class SwapInteractorImpl @Inject constructor( provider: SwapProvider, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, - selectedFee: FeeType, ): Pair { return provider to loadCexQuoteData( exchangeProviderType = ExchangeProviderType.CEX, @@ -353,13 +350,27 @@ internal class SwapInteractorImpl @Inject constructor( isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, - selectedFee = selectedFee, ) } + private suspend fun manageWarnings(fromToken: CryptoCurrency, amount: SwapAmount): List { + val userWalletId = getSelectedWallet()?.walletId ?: return emptyList() + val existentialDeposit = repository.getExistentialDeposit(userWalletId, fromToken.network) + val warnings = mutableListOf() + if (existentialDeposit != null) { + val nativeBalance = userWalletManager.getNativeTokenBalance( + fromToken.network.backendId, + fromToken.network.derivationPath.value, + ) ?: ProxyAmount.empty() + if (nativeBalance.value.minus(amount.value) < existentialDeposit) { + warnings.add(Warning.ExistentialDepositWarning(existentialDeposit)) + } + } + return warnings + } + override suspend fun onSwap( swapProvider: SwapProvider, - networkId: String, swapData: SwapDataModel?, currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, @@ -387,7 +398,7 @@ internal class SwapInteractorImpl @Inject constructor( } ExchangeProviderType.DEX -> { onSwapDex( - networkId = networkId, + networkId = currencyToSend.currency.network.backendId, swapData = requireNotNull(swapData), currencyToSend = currencyToSend.currency, currencyToGet = currencyToGet.currency, @@ -403,7 +414,6 @@ internal class SwapInteractorImpl @Inject constructor( selectedFee: FeeType, fromToken: CryptoCurrencyStatus, amountToSwap: String, - networkId: String, ): SwapState.QuotesLoadedState { val amountDecimal = toBigDecimalOrNull(amountToSwap) if (amountDecimal == null || amountDecimal.signum() == 0) { @@ -411,17 +421,16 @@ internal class SwapInteractorImpl @Inject constructor( } val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency)) val includeFeeInAmount = getIncludeFeeInAmount( - networkId = networkId, + networkId = fromToken.currency.network.backendId, txFee = state.txFee, amount = amount, fromToken = fromToken.currency, - selectedFee = selectedFee, ) return state.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = state.preparedSwapConfigState.copy( - isBalanceEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, + isBalanceEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, includeFeeInAmount = includeFeeInAmount, ), ) @@ -437,6 +446,7 @@ internal class SwapInteractorImpl @Inject constructor( ): TxState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val amount = SwapAmount(amountDecimal, getTokenDecimals(currencyToSend)) + val derivationPath = currencyToSend.network.derivationPath.value val result = transactionManager.sendTransaction( txData = SwapTxData( networkId = networkId, @@ -462,11 +472,13 @@ internal class SwapInteractorImpl @Inject constructor( amount, currencyToSend.symbol, ), + fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( swapData.toTokenAmount, currencyToGet.symbol, ), - txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), + toAmountValue = swapData.toTokenAmount.value, + txHash = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), timestamp = System.currentTimeMillis(), ) } @@ -478,6 +490,7 @@ internal class SwapInteractorImpl @Inject constructor( } } + @Suppress("LongMethod") private suspend fun onSwapCex( currencyToSend: CryptoCurrencyStatus, currencyToGet: CryptoCurrencyStatus, @@ -497,15 +510,25 @@ internal class SwapInteractorImpl @Inject constructor( providerId = swapProvider.providerId, rateType = RateType.FLOAT, toAddress = currencyToGet.value.networkAddress?.defaultAddress?.value ?: "", - ) + ).getOrNull() + val exchangeDataCex = exchangeData?.transaction as? ExpressTransactionModel.CEX ?: return TxState.UnknownError + val txExtras = transactionManager.getMemoExtras( + currencyToSend.currency.network.backendId, + exchangeDataCex.txExtraId, + ) + if (txExtras == null && exchangeDataCex.txExtraId != null) { + return TxState.UnknownError + } val txData = walletManagersFacade.createTransaction( amount = amount.value.convertToAmount(currencyToSend.currency), fee = getFeeForTransaction(txFee), memo = null, - destination = (exchangeData.dataModel?.transaction as ExpressTransactionModel.CEX).txTo, + destination = exchangeDataCex.txTo, userWalletId = userWalletId, network = currencyToSend.currency.network, + )?.copy( + extras = txExtras, ) val result = sendTransactionUseCase( @@ -514,8 +537,9 @@ internal class SwapInteractorImpl @Inject constructor( network = currencyToSend.currency.network, ) - val externalUrl = (exchangeData.dataModel.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl + val externalUrl = (exchangeData.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl + val derivationPath = currencyToSend.currency.network.derivationPath.value return result.fold( ifLeft = { when (it) { @@ -533,8 +557,9 @@ internal class SwapInteractorImpl @Inject constructor( currencyToGet = currencyToGet, amount = amount, swapProvider = swapProvider, - swapDataModel = exchangeData.dataModel, + swapDataModel = exchangeData, timestamp = timestamp, + txExternalUrl = externalUrl.orEmpty(), ) storeLastCryptoCurrencyId(currencyToGet.currency) TxState.TxSent( @@ -542,11 +567,13 @@ internal class SwapInteractorImpl @Inject constructor( amount, currencyToSend.currency.symbol, ), + fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( - exchangeData.dataModel.toTokenAmount, + exchangeData.toTokenAmount, currencyToGet.currency.symbol, ), - txAddress = userWalletManager.getLastTransactionHash( + toAmountValue = exchangeData.toTokenAmount.value, + txHash = userWalletManager.getLastTransactionHash( currencyToSend.currency.network.backendId, derivationPath, ).orEmpty(), @@ -567,10 +594,11 @@ internal class SwapInteractorImpl @Inject constructor( ) return if (fee.gasLimit != 0) { + val feeAmountWithDecimals = feeAmountValue.movePointRight(fee.decimals) Fee.Ethereum( amount = feeAmount, gasLimit = fee.gasLimit.toBigInteger(), - gasPrice = (feeAmountValue / fee.gasLimit.toBigDecimal()).toBigInteger(), + gasPrice = (feeAmountWithDecimals / fee.gasLimit.toBigDecimal()).toBigInteger(), ) } else { Fee.Common(feeAmount) @@ -584,6 +612,7 @@ internal class SwapInteractorImpl @Inject constructor( swapProvider: SwapProvider, swapDataModel: SwapDataModel, timestamp: Long, + txExternalUrl: String, ) { swapTransactionRepository.storeTransaction( userWalletId = UserWalletId(userWalletManager.getWalletId()), @@ -595,6 +624,12 @@ internal class SwapInteractorImpl @Inject constructor( timestamp = timestamp, fromCryptoAmount = amount.value, toCryptoAmount = swapDataModel.toTokenAmount.value, + status = ExchangeStatusModel( + providerId = swapProvider.providerId, + status = ExchangeStatus.New, + txId = swapDataModel.transaction.txId, + txExternalUrl = txExternalUrl, + ), ), ) } @@ -629,11 +664,6 @@ internal class SwapInteractorImpl @Inject constructor( return repository.getNativeTokenForNetwork(networkId) } - @Deprecated("used in old swap mechanism") - private fun getTangemFee(): Double { - return repository.getTangemFee() - } - private fun getTokenDecimals(token: CryptoCurrency): Int { return if (token is CryptoCurrency.Token) { token.decimals @@ -654,7 +684,7 @@ internal class SwapInteractorImpl @Inject constructor( val allowance = repository.getAllowance( userWalletId = userWallet.walletId, networkId = networkId, - derivationPath = derivationPath, + derivationPath = fromToken.network.derivationPath.value, tokenDecimalCount = getTokenDecimals(fromToken), tokenAddress = getTokenAddress(fromToken), spenderAddress = spenderAddress, @@ -696,7 +726,6 @@ internal class SwapInteractorImpl @Inject constructor( provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, - selectedFee: FeeType, ): SwapState { val fromToken = fromTokenStatus.currency val toToken = toTokenStatus.currency @@ -712,7 +741,6 @@ internal class SwapInteractorImpl @Inject constructor( txFee = txFee, amount = amount, fromToken = fromToken, - selectedFee = selectedFee, ) val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { includeFeeInAmount.amountSubtractFee @@ -749,7 +777,7 @@ internal class SwapInteractorImpl @Inject constructor( private suspend fun getQuotesState( exchangeProviderType: ExchangeProviderType, - quoteDataModel: AggregatedSwapDataModel, + quoteDataModel: Either, amount: SwapAmount, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, @@ -759,57 +787,63 @@ internal class SwapInteractorImpl @Inject constructor( txFee: TxFeeState, includeFeeInAmount: IncludeFeeInAmount, ): SwapState { - val quoteModel = quoteDataModel.dataModel - if (quoteModel != null) { - val swapState = updateBalances( - networkId = networkId, - fromTokenStatus = fromToken, - toTokenStatus = toToken, - fromTokenAmount = amount, - toTokenAmount = quoteModel.toTokenAmount, - swapData = null, - txFeeState = txFee, - ) + return quoteDataModel.fold( + ifRight = { quoteModel -> + val swapState = updateBalances( + networkId = networkId, + fromTokenStatus = fromToken, + toTokenStatus = toToken, + fromTokenAmount = amount, + toTokenAmount = quoteModel.toTokenAmount, + swapData = null, + txFeeState = txFee, + exchangeProviderType = exchangeProviderType, + ).copy( + warnings = manageWarnings(fromToken.currency, amount), + ) - return when (exchangeProviderType) { - ExchangeProviderType.DEX -> { - val state = updatePermissionState( - networkId = networkId, - fromTokenStatus = fromToken, - swapAmount = amount, - quotesLoadedState = swapState, - isAllowedToSpend = isAllowedToSpend, - spenderAddress = quoteModel.allowanceContract, - ) - state.copy( - preparedSwapConfigState = state.preparedSwapConfigState.copy( + when (exchangeProviderType) { + ExchangeProviderType.DEX -> { + val state = updatePermissionState( + networkId = networkId, + fromTokenStatus = fromToken, + swapAmount = amount, + quotesLoadedState = swapState, isAllowedToSpend = isAllowedToSpend, - isBalanceEnough = isBalanceWithoutFeeEnough, - ), - ) + spenderAddress = quoteModel.allowanceContract, + ) + state.copy( + preparedSwapConfigState = state.preparedSwapConfigState.copy( + isAllowedToSpend = isAllowedToSpend, + isBalanceEnough = isBalanceWithoutFeeEnough, + ), + ) + } + ExchangeProviderType.CEX -> { + swapState.copy( + permissionState = PermissionDataState.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, + isAllowedToSpend = isAllowedToSpend, + isBalanceEnough = isBalanceWithoutFeeEnough, + hasOutgoingTransaction = hasOutgoingTransaction(fromToken), + includeFeeInAmount = includeFeeInAmount, + ), + ) + } } - ExchangeProviderType.CEX -> { - swapState.copy( - permissionState = PermissionDataState.Empty, - preparedSwapConfigState = PreparedSwapConfigState( - isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, - isAllowedToSpend = isAllowedToSpend, - isBalanceEnough = isBalanceWithoutFeeEnough, - includeFeeInAmount = includeFeeInAmount, - ), - ) - } - } - } else { - val rates = getQuotes(fromToken.currency.id) - val fromTokenSwapInfo = TokenSwapInfo( - tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) - ?: BigDecimal.ZERO, - cryptoCurrencyStatus = fromToken, - ) - return SwapState.SwapError(fromTokenSwapInfo, quoteDataModel.error) - } + }, + ifLeft = { error -> + val rates = getQuotes(fromToken.currency.id) + val fromTokenSwapInfo = TokenSwapInfo( + tokenAmount = amount, + amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) + ?: BigDecimal.ZERO, + cryptoCurrencyStatus = fromToken, + ) + return SwapState.SwapError(fromTokenSwapInfo, error, includeFeeInAmount) + }, + ) } private suspend fun getIncludeFeeInAmount( @@ -817,42 +851,47 @@ internal class SwapInteractorImpl @Inject constructor( txFee: TxFeeState, amount: SwapAmount, fromToken: CryptoCurrency, - selectedFee: FeeType, ): IncludeFeeInAmount { - if (fromToken is CryptoCurrency.Token) { - return IncludeFeeInAmount.Excluded - } - val tokenForFeeBalance = - userWalletManager.getNativeTokenBalance(networkId, derivationPath) ?: ProxyAmount.empty() + userWalletManager.getNativeTokenBalance( + networkId, + fromToken.network.derivationPath.value, + ) ?: ProxyAmount.empty() - if (amount.value > tokenForFeeBalance.value) { - return IncludeFeeInAmount.BalanceNotEnough - } val feeValue = when (txFee) { TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> if (selectedFee == FeeType.NORMAL) { - txFee.normalFee.feeValue - } else { - txFee.priorityFee.feeValue - } + is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue is TxFeeState.SingleFeeState -> txFee.fee.feeValue } val amountWithFee = amount.value + feeValue - return if (amountWithFee < tokenForFeeBalance.value) { - IncludeFeeInAmount.Excluded - } else { - if (feeValue < amount.value) { - IncludeFeeInAmount.Included( - SwapAmount( - tokenForFeeBalance.value - feeValue, - transactionManager.getNativeTokenDecimals(networkId), - ), - ) - } else { + + return when { + fromToken is CryptoCurrency.Token -> { + if (feeValue > tokenForFeeBalance.value) { + IncludeFeeInAmount.BalanceNotEnough + } else { + IncludeFeeInAmount.Excluded + } + } + amount.value > tokenForFeeBalance.value -> { IncludeFeeInAmount.BalanceNotEnough } + amountWithFee < tokenForFeeBalance.value -> { + IncludeFeeInAmount.Excluded + } + else -> { + if (feeValue < amount.value) { + IncludeFeeInAmount.Included( + SwapAmount( + tokenForFeeBalance.value - feeValue, + transactionManager.getNativeTokenDecimals(networkId), + ), + ) + } else { + IncludeFeeInAmount.BalanceNotEnough + } + } } } @@ -870,7 +909,7 @@ internal class SwapInteractorImpl @Inject constructor( /** * Load swap data calls only if spend is allowed for token contract address */ - @Suppress("LongParameterList") + @Suppress("LongParameterList", "LongMethod") private suspend fun loadDexSwapData( provider: SwapProvider, networkId: String, @@ -879,7 +918,7 @@ internal class SwapInteractorImpl @Inject constructor( amount: SwapAmount, selectedFee: FeeType, ): SwapState { - repository.getExchangeData( + return repository.getExchangeData( fromContractAddress = fromToken.currency.getContractAddress(), fromNetwork = fromToken.currency.network.backendId, toContractAddress = toToken.currency.getContractAddress(), @@ -890,9 +929,8 @@ internal class SwapInteractorImpl @Inject constructor( providerId = provider.providerId, rateType = RateType.FLOAT, toAddress = toToken.value.networkAddress?.defaultAddress?.value ?: "", - ).let { - val swapData = it.dataModel - if (swapData != null) { + ).fold( + ifRight = { swapData -> val feeData = transactionManager.getFee( networkId = networkId, amountToSend = amount.value, @@ -900,7 +938,7 @@ internal class SwapInteractorImpl @Inject constructor( destinationAddress = swapData.transaction.txTo, increaseBy = INCREASE_GAS_LIMIT_BY, data = (swapData.transaction as ExpressTransactionModel.DEX).txData, - derivationPath = derivationPath, + derivationPath = fromToken.currency.network.derivationPath.value, ) val txFeeState = when (feeData) { is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId) @@ -922,17 +960,21 @@ internal class SwapInteractorImpl @Inject constructor( toTokenAmount = swapData.toTokenAmount, swapData = swapData, txFeeState = txFeeState, + exchangeProviderType = ExchangeProviderType.DEX, ) - return swapState.copy( + swapState.copy( permissionState = PermissionDataState.Empty, + warnings = manageWarnings(fromToken.currency, amount), preparedSwapConfigState = PreparedSwapConfigState( isAllowedToSpend = true, isBalanceEnough = isBalanceIncludeFeeEnough, isFeeEnough = isFeeEnough, + hasOutgoingTransaction = hasOutgoingTransaction(fromToken), includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex ), ) - } else { + }, + ifLeft = { error -> val rates = getQuotes(fromToken.currency.id) val fromTokenSwapInfo = TokenSwapInfo( tokenAmount = amount, @@ -940,12 +982,13 @@ internal class SwapInteractorImpl @Inject constructor( ?: BigDecimal.ZERO, cryptoCurrencyStatus = fromToken, ) - return SwapState.SwapError( + SwapState.SwapError( fromTokenSwapInfo, - it.error, + error, + IncludeFeeInAmount.Excluded, ) - } - } + }, + ) } @Suppress("LongParameterList") @@ -957,6 +1000,7 @@ internal class SwapInteractorImpl @Inject constructor( toTokenAmount: SwapAmount, swapData: SwapDataModel?, txFeeState: TxFeeState, + exchangeProviderType: ExchangeProviderType, ): SwapState.QuotesLoadedState { val fromToken = fromTokenStatus.currency val toToken = toTokenStatus.currency @@ -981,10 +1025,10 @@ internal class SwapInteractorImpl @Inject constructor( fromRate = rates[fromToken.id]?.fiatRate?.toDouble() ?: 0.0, toTokenAmount = toTokenAmount.value, toRate = rates[toToken.id]?.fiatRate?.toDouble() ?: 0.0, + exchangeProviderType = exchangeProviderType, ), networkCurrency = userWalletManager.getNetworkCurrency(networkId), swapDataModel = swapData, - tangemFee = getTangemFee(), txFee = txFeeState, ) } @@ -995,9 +1039,8 @@ internal class SwapInteractorImpl @Inject constructor( networkId: String, ): TxFeeState { getSelectedWalletSyncUseCase().getOrNull()?.walletId?.let { userWalletId -> - val txFeeResult = getFeeUseCase( + val txFeeResult = estimateFeeUseCase( amount = amount.value, - destination = fromToken.value.networkAddress?.defaultAddress?.value ?: "", userWalletId = userWalletId, cryptoCurrency = fromToken.currency, ).firstOrNull() @@ -1040,6 +1083,7 @@ internal class SwapInteractorImpl @Inject constructor( permissionState = PermissionDataState.PermissionLoading, ) } + val derivationPath = fromToken.network.derivationPath.value // setting up amount for approve with given amount for swap [SwapApproveType.Limited] val transactionData = getApproveData( networkId = networkId, @@ -1083,7 +1127,7 @@ internal class SwapInteractorImpl @Inject constructor( permissionState = PermissionDataState.PermissionReadyForRequest( currency = fromToken.symbol, amount = INFINITY_SYMBOL, - walletAddress = getWalletAddress(networkId), + walletAddress = getWalletAddress(networkId, derivationPath), spenderAddress = getTokenAddress(fromToken), requestApproveData = RequestApproveStateData( fee = feeState, @@ -1167,8 +1211,10 @@ internal class SwapInteractorImpl @Inject constructor( val decimals = transactionManager.getNativeTokenDecimals(networkId) return when (this) { is TransactionFee.Choosable -> { - val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO - val feePriority = this.priority.amount.value ?: BigDecimal.ZERO + val normalFee = this.normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND) + val priorityFee = this.priority.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND) + val feeNormal = normalFee.amount.value ?: BigDecimal.ZERO + val feePriority = priorityFee.amount.value ?: BigDecimal.ZERO val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0] val priorityFiatValue = getFormattedFiatFees(networkId, feePriority)[0] @@ -1183,7 +1229,7 @@ internal class SwapInteractorImpl @Inject constructor( TxFeeState.MultipleFeeState( normalFee = TxFee( feeValue = feeNormal, - gasLimit = this.normal.getGasLimit(), + gasLimit = normalFee.getGasLimit(), feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, decimals = decimals, @@ -1192,7 +1238,7 @@ internal class SwapInteractorImpl @Inject constructor( ), priorityFee = TxFee( feeValue = feePriority, - gasLimit = this.priority.getGasLimit(), + gasLimit = priorityFee.getGasLimit(), feeFiatFormatted = priorityFiatValue, feeCryptoFormatted = priorityCryptoFee, decimals = decimals, @@ -1223,6 +1269,30 @@ internal class SwapInteractorImpl @Inject constructor( } } + /** + * Workaround to increase gas limit cause we calculate fee for random address + */ + private fun Fee.increaseGasLimitBy(percentage: Int): Fee { + if (this !is Fee.Ethereum) return this + val gasLimit = this.gasLimit + val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals) + ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) + val increasedGasLimit = gasLimit + .multiply(percentage.toBigInteger()) + .divide(hundredPercent) + val increasedAmount = this.amount.copy( + value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals), + ) + return this.copy( + amount = increasedAmount, + gasLimit = increasedGasLimit, + ) + } + + private fun hasOutgoingTransaction(cryptoCurrencyStatuses: CryptoCurrencyStatus): Boolean { + return cryptoCurrencyStatuses.value.pendingTransactions.any { it.isOutgoing } + } + private fun Fee.getGasLimit(): Int { return when (this) { is Fee.Common -> 0 @@ -1250,7 +1320,7 @@ internal class SwapInteractorImpl @Inject constructor( } } - private suspend fun getWalletAddress(networkId: String): String { + private suspend fun getWalletAddress(networkId: String, derivationPath: String?): String { return userWalletManager.getWalletAddress(networkId, derivationPath) } @@ -1274,7 +1344,10 @@ internal class SwapInteractorImpl @Inject constructor( if (fee == null) { return false } - val nativeTokenBalance = userWalletManager.getNativeTokenBalance(networkId, derivationPath) + val nativeTokenBalance = userWalletManager.getNativeTokenBalance( + networkId, + fromToken.network.derivationPath.value, + ) val percentsToFeeIncrease = BigDecimal.ONE return when (fromToken) { is CryptoCurrency.Coin -> { @@ -1299,10 +1372,15 @@ internal class SwapInteractorImpl @Inject constructor( fromRate: Double, toTokenAmount: BigDecimal, toRate: Double, - ): Float { - val toTokenFiatValue = toTokenAmount.multiply(toRate.toBigDecimal()) + exchangeProviderType: ExchangeProviderType, + ): PriceImpact { val fromTokenFiatValue = fromTokenAmount.multiply(fromRate.toBigDecimal()) - return (BigDecimal.ONE - toTokenFiatValue.divide(fromTokenFiatValue, 2, RoundingMode.HALF_UP)).toFloat() + val toTokenFiatValue = toTokenAmount.multiply(toRate.toBigDecimal()) + val value = (BigDecimal.ONE - toTokenFiatValue.divide(fromTokenFiatValue, 2, RoundingMode.HALF_UP)).toFloat() + if (exchangeProviderType == ExchangeProviderType.CEX) { + return PriceImpact.Value(value) + } + return PriceImpact.ValueWithNotify(value) } private suspend fun getApproveData( @@ -1341,6 +1419,7 @@ internal class SwapInteractorImpl @Inject constructor( companion object { @Suppress("UnusedPrivateMember") private const val INCREASE_GAS_LIMIT_BY = 112 // 12% + private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% private const val INFINITY_SYMBOL = "∞" private val ONE_INCH_SUPPORTED_NETWORKS = listOf( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt index 2db58ee01d..e00dadb2a9 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -2,6 +2,7 @@ package com.tangem.feature.swap.domain import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel import kotlinx.coroutines.flow.Flow @@ -15,7 +16,7 @@ interface SwapTransactionRepository { transaction: SavedSwapTransactionModel, ) - fun getTransactions( + suspend fun getTransactions( userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID, ): Flow?> @@ -27,6 +28,8 @@ interface SwapTransactionRepository { txId: String, ) + suspend fun storeTransactionState(txId: String, status: ExchangeStatusModel) + suspend fun storeLastSwappedCryptoCurrencyId(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID) suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? 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 f6bf9093c9..d299f7824e 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 @@ -13,6 +13,7 @@ import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.swap.domain.* +import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.lib.crypto.TransactionManager import com.tangem.lib.crypto.UserWalletManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt deleted file mode 100644 index 45a895181e..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.feature.swap.domain.models.data - -import com.tangem.feature.swap.domain.models.DataError - -/** - * Model that aggregate data model from repository return with error [DataError] if it exists - * - * @param T model type - * @property dataModel - * @property error possible from repository [DataError] - */ -data class AggregatedSwapDataModel( - val dataModel: T?, - val error: DataError = DataError.UnknownError, -) \ No newline at end of file diff --git a/features/swap/presentation/build.gradle.kts b/features/swap/presentation/build.gradle.kts index b2ea5cda48..a08a338a18 100644 --- a/features/swap/presentation/build.gradle.kts +++ b/features/swap/presentation/build.gradle.kts @@ -26,6 +26,10 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) + implementation(projects.domain.settings) + implementation(projects.features.swap.domain) + implementation(projects.features.swap.domain.api) + implementation(projects.features.swap.domain.models) /** AndroidX */ implementation(deps.androidx.activity.compose) @@ -48,13 +52,10 @@ dependencies { implementation(projects.features.swap.api) implementation(projects.features.tokendetails.api) - /** Domain */ - implementation(projects.features.swap.domain) - implementation(projects.domain.tokens.models) - implementation(projects.domain.settings) - /** Other libraries */ implementation(deps.compose.shimmer) + implementation(deps.compose.accompanist.webView) + implementation(deps.compose.accompanist.systemUiController) implementation(deps.kotlin.serialization) implementation(deps.kotlin.immutable.collections) implementation(deps.timber) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index b128e88b19..2fd8fa08ac 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.models.ApproveType private const val SWAP_CATEGORY = "Swap" @@ -38,9 +39,17 @@ sealed class SwapEvents( object ButtonGivePermissionClicked : SwapEvents(event = "Button - Give permission") - data class ButtonPermissionApproveClicked(val sendToken: String, val receiveToken: String) : SwapEvents( + data class ButtonPermissionApproveClicked( + val sendToken: String, + val receiveToken: String, + val approveType: ApproveType, + ) : SwapEvents( event = "Button - Permission Approve", - params = mapOf("Send Token" to sendToken, "Receive Token" to receiveToken), + params = mapOf( + "Send Token" to sendToken, + "Receive Token" to receiveToken, + "Type" to if (approveType == ApproveType.LIMITED) "Current Transaction" else "Unlimited", + ), ) object ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel") diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt index fb12b518d5..40dd04e697 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt @@ -1,20 +1,18 @@ package com.tangem.feature.swap.converters import com.tangem.core.ui.components.currency.tokenicon.TokenIconState -import com.tangem.core.ui.extensions.getTintForTokenIcon -import com.tangem.core.ui.extensions.networkIconResId -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon +import com.tangem.core.ui.extensions.* import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo -import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup +import com.tangem.feature.swap.models.CurrenciesGroupWithFromCurrency import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenBalanceData import com.tangem.feature.swap.models.TokenToSelectState import com.tangem.utils.Provider +import com.tangem.feature.swap.presentation.R import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList @@ -23,13 +21,21 @@ class TokensDataConverter( private val onTokenSelected: (String) -> Unit, private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, -) : Converter { +) : Converter { - override fun convert(value: CurrenciesGroup): SwapSelectTokenStateHolder { - val availableTitle = TokenToSelectState.Title(stringReference("My tokens")) // todo replace with resource - val unavailableTitle = TokenToSelectState.Title(stringReference("My tokens")) // todo replace with resource + override fun convert(value: CurrenciesGroupWithFromCurrency): SwapSelectTokenStateHolder { + val group = value.group + val availableTitle = TokenToSelectState.Title( + resourceReference(R.string.exchange_tokens_available_tokens_header), + ) + val unavailableTitle = TokenToSelectState.Title( + resourceReference( + R.string.exchange_tokens_unavailable_tokens_header, + wrappedList(value.fromCurrency.name), + ), + ) return SwapSelectTokenStateHolder( - availableTokens = value.available.map { tokenWithBalanceToTokenToSelect(it, true) } + availableTokens = group.available.map { tokenWithBalanceToTokenToSelect(it, true) } .toMutableList() .apply { if (this.isNotEmpty()) { @@ -37,7 +43,7 @@ class TokensDataConverter( } } .toImmutableList(), - unavailableTokens = value.unavailable.map { tokenWithBalanceToTokenToSelect(it, false) } + unavailableTokens = group.unavailable.map { tokenWithBalanceToTokenToSelect(it, false) } .toMutableList() .apply { if (this.isNotEmpty()) { @@ -47,6 +53,7 @@ class TokensDataConverter( .toImmutableList(), onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, + afterSearch = group.afterSearch, ) } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/CurrenciesGroupWithFromCurrency.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/CurrenciesGroupWithFromCurrency.kt new file mode 100644 index 0000000000..8251580bb8 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/CurrenciesGroupWithFromCurrency.kt @@ -0,0 +1,9 @@ +package com.tangem.feature.swap.models + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup + +data class CurrenciesGroupWithFromCurrency( + val group: CurrenciesGroup, + val fromCurrency: CryptoCurrency, +) \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index ac6762d7b7..585afb1921 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -7,6 +7,7 @@ import kotlinx.collections.immutable.ImmutableList data class SwapSelectTokenStateHolder( val availableTokens: ImmutableList, val unavailableTokens: ImmutableList, + val afterSearch: Boolean, val onSearchEntered: (String) -> Unit, val onTokenSelected: (String) -> Unit, ) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 61f37ef236..38c34d868f 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -1,10 +1,14 @@ package com.tangem.feature.swap.models import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState @@ -15,17 +19,19 @@ data class SwapStateHolder( val blockchainId: String, // not the same as networkId, its local id in app val warnings: List = emptyList(), val alert: SwapWarning.GenericWarning? = null, - val updateInProgress: Boolean = false, + val changeCardsButtonState: ChangeCardsButtonState = ChangeCardsButtonState.ENABLED, val providerState: ProviderState, val fee: FeeItemState = FeeItemState.Empty, val permissionState: SwapPermissionState = SwapPermissionState.Empty, + val priceImpact: PriceImpact, val successState: SwapSuccessStateHolder? = null, val selectTokenState: SwapSelectTokenStateHolder? = null, val bottomSheetConfig: TangemBottomSheetConfig? = null, val swapButton: SwapButton, + val tosState: TosState? = null, val onRefresh: () -> Unit, val onBackClicked: () -> Unit, @@ -63,22 +69,36 @@ sealed class SwapCardState { data class SwapButton( val enabled: Boolean, - val loading: Boolean = false, val onClick: () -> Unit, ) sealed interface TransactionCardType { - data class SendCard( + val headerResId: Int + + data class Inputtable( val onAmountChanged: ((String) -> Unit), val onFocusChanged: ((Boolean) -> Unit), + @StringRes override val headerResId: Int = R.string.swapping_from_title, ) : TransactionCardType - data class ReceiveCard( + data class ReadOnly( val highPriceImpact: String? = null, + @StringRes override val headerResId: Int = R.string.swapping_to_title, ) : TransactionCardType } +data class TosState( + val tosLink: LegalState?, + val policyLink: LegalState?, +) + +data class LegalState( + val title: TextReference, + val link: String, + val onClick: (String) -> Unit, +) + sealed interface SwapWarning { data class PermissionNeeded(val notificationConfig: NotificationConfig) : SwapWarning object InsufficientFunds : SwapWarning @@ -99,8 +119,14 @@ sealed interface SwapWarning { data class TooSmallAmountWarning(val notificationConfig: NotificationConfig) : SwapWarning data class UnableToCoverFeeWarning(val notificationConfig: NotificationConfig) : SwapWarning data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning + data class GeneralInformational(val notificationConfig: NotificationConfig) : SwapWarning + data class TransactionInProgressWarning(val title: TextReference, val description: TextReference) : SwapWarning } enum class GenericWarningType { NETWORK, OTHER +} + +enum class ChangeCardsButtonState { + ENABLED, DISABLED, UPDATE_IN_PROGRESS } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt index 48a3d55f55..258039517e 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -21,4 +21,6 @@ data class UiActions( val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, val onBuyClick: () -> Unit, + val onPolicyClick: (String) -> Unit, + val onTosClick: (String) -> Unit, ) \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt index e01a8b8058..6ca10e195f 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt @@ -11,6 +11,7 @@ sealed class FeeItemState { val amountCrypto: String, val symbolCrypto: String, val amountFiatFormatted: String, + val explanation: TextReference?, val isClickable: Boolean, val onClick: () -> Unit, ) : FeeItemState() diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt index 84254a5716..265631000e 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt @@ -25,7 +25,7 @@ sealed class ProviderState { val subtitle: TextReference, val selectionType: SelectionType, val additionalBadge: AdditionalBadge, - val percentLowerThenBest: Float = 0f, + val percentLowerThenBest: PercentLowerThanBest = PercentLowerThanBest.Empty, override val onProviderClick: (String) -> Unit, ) : ProviderState() @@ -50,18 +50,35 @@ sealed class ProviderState { } } +sealed class PercentLowerThanBest { + data class Value(val value: Float) : PercentLowerThanBest() + object Empty : PercentLowerThanBest() +} + object ProviderPercentDiffComparator : Comparator { override fun compare(o1: ProviderState, o2: ProviderState): Int { if (o1 is ProviderState.Content && o2 !is ProviderState.Content) { - return 1 - } - if (o1 !is ProviderState.Content && o2 is ProviderState.Content) { return -1 } - return if (o1 is ProviderState.Content && o2 is ProviderState.Content) { - o1.percentLowerThenBest.compareTo(o2.percentLowerThenBest) + if (o1 !is ProviderState.Content && o2 is ProviderState.Content) { + return 1 + } + if (o1 is ProviderState.Content && o2 is ProviderState.Content) { + val o1Percent = o1.percentLowerThenBest + val o2Percent = o2.percentLowerThenBest + if (o1Percent is PercentLowerThanBest.Value && o2Percent !is PercentLowerThanBest.Value) { + return -1 + } + if (o1Percent !is PercentLowerThanBest.Value && o2Percent is PercentLowerThanBest.Value) { + return 1 + } + return if (o1Percent is PercentLowerThanBest.Value && o2Percent is PercentLowerThanBest.Value) { + o1Percent.value.compareTo(o2Percent.value) + } else { + 0 + } } else { - 0 + return 0 } } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/WebViewBottomSheetConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/WebViewBottomSheetConfig.kt new file mode 100644 index 0000000000..ee2bce3961 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/WebViewBottomSheetConfig.kt @@ -0,0 +1,5 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +internal data class WebViewBottomSheetConfig(val url: String) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt index d0f47a62a2..3c3de0d27f 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt @@ -109,6 +109,7 @@ private fun ChooseFeeBottomSheetContent_Preview() { amountCrypto = "1000", symbolCrypto = "MATIC", amountFiatFormatted = "(10$)", + explanation = null, isClickable = false, onClick = {}, ), @@ -118,6 +119,7 @@ private fun ChooseFeeBottomSheetContent_Preview() { amountCrypto = "2000", symbolCrypto = "MATIC", amountFiatFormatted = "(10$)", + explanation = null, isClickable = false, onClick = {}, ), diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt index 9f41e55e75..51757b8c16 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt @@ -4,11 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -17,6 +19,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig +import com.tangem.feature.swap.models.states.PercentLowerThanBest import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.toImmutableList @@ -31,16 +34,16 @@ fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) { } } +@Suppress("LongMethod") @Composable private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetConfig) { - Column { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = stringResource(R.string.express_choose_providers_title), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, modifier = Modifier - .padding(top = TangemTheme.dimens.spacing10) - .align(Alignment.CenterHorizontally), + .padding(top = TangemTheme.dimens.spacing10), ) Text( text = stringResource(R.string.express_choose_providers_subtitle), @@ -48,13 +51,17 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC color = TangemTheme.colors.text.secondary, modifier = Modifier .padding(top = TangemTheme.dimens.spacing10) - .padding(horizontal = TangemTheme.dimens.spacing56) - .align(Alignment.CenterHorizontally), + .padding(horizontal = TangemTheme.dimens.spacing56), textAlign = TextAlign.Center, ) Column( modifier = Modifier - .padding(TangemTheme.dimens.spacing16) + .padding( + top = TangemTheme.dimens.spacing16, + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing14, + ) .background( color = TangemTheme.colors.background.action, shape = TangemTheme.shapes.roundedCornersXMedium, @@ -71,10 +78,28 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC enabled = provider.onProviderClick != null, onClick = { provider.onProviderClick?.invoke(provider.id) }, ) - .padding(TangemTheme.dimens.spacing12), + .padding( + top = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ), ) } } + Icon( + painterResource(id = R.drawable.ic_lightning_16), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + Text( + text = stringResource(R.string.express_more_providers_soon), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.icon.informative, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing6, bottom = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing56), + textAlign = TextAlign.Center, + ) } } @@ -89,7 +114,7 @@ private fun ChooseProviderBottomSheet_Preview() { iconUrl = "", subtitle = stringReference("1 000 000"), additionalBadge = ProviderState.AdditionalBadge.BestTrade, - percentLowerThenBest = -1.0f, + percentLowerThenBest = PercentLowerThanBest.Value(-1.0f), selectionType = ProviderState.SelectionType.SELECT, onProviderClick = {}, ), diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt index f379ae3511..f5385bf456 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt @@ -3,6 +3,8 @@ package com.tangem.feature.swap.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.material3.Divider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,12 +26,13 @@ fun FeeItemBlock(state: FeeItemState) { @Composable fun FeeItem(state: FeeItemState.Content) { - Box( + Column( modifier = Modifier .background( color = TangemTheme.colors.background.action, shape = TangemTheme.shapes.roundedCornersXMedium, ) + .padding(start = TangemTheme.dimens.spacing12) .clip(shape = TangemTheme.shapes.roundedCornersXMedium) .clickable( onClick = state.onClick, @@ -40,13 +43,32 @@ fun FeeItem(state: FeeItemState.Content) { val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})" SimpleActionRow( modifier = Modifier.padding( - start = TangemTheme.dimens.spacing12, top = TangemTheme.dimens.spacing12, ), title = state.title.resolveReference(), description = description, isClickable = state.isClickable, ) + state.explanation?.let { + Divider( + color = TangemTheme.colors.stroke.primary, + thickness = TangemTheme.dimens.size0_5, + modifier = Modifier.padding( + top = TangemTheme.dimens.spacing10, + bottom = TangemTheme.dimens.spacing10, + end = TangemTheme.dimens.spacing2, + ), + ) + Text( + text = it.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding( + bottom = TangemTheme.dimens.spacing10, + end = TangemTheme.dimens.spacing16, + ), + ) + } } } @@ -59,6 +81,10 @@ private fun FeeItemPreview() { amountCrypto = "1000", symbolCrypto = "MATIC", amountFiatFormatted = "(1000$)", + explanation = stringReference( + "Additionally, the network fee for sending the exchanged funds back to your address is " + + "included in the rate", + ), isClickable = false, onClick = {}, ) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt index e5fe4ef2c9..c1a94da8f7 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import coil.compose.SubcomposeAsyncImage @@ -24,6 +25,7 @@ import com.tangem.core.ui.components.SpacerH24 import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.models.states.PercentLowerThanBest import com.tangem.feature.swap.models.states.ProviderState /** @@ -151,8 +153,10 @@ private fun ProviderContentState( maxLines = 1, ) } - if (state.percentLowerThenBest > 0f) { - AnimatedContent(targetState = state.percentLowerThenBest, label = "") { + if (state.percentLowerThenBest is PercentLowerThanBest.Value && + state.percentLowerThenBest.value > 0 + ) { + AnimatedContent(targetState = state.percentLowerThenBest.value, label = "") { Text( text = "-$it%", style = TangemTheme.typography.body2, @@ -243,7 +247,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxWidth()) { Column { Text( - text = "Provider", + text = stringResource(R.string.express_provider), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.secondary, modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), @@ -256,11 +260,14 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) { ), ) { CircularProgressIndicator( - modifier = Modifier.size(TangemTheme.dimens.size16), + modifier = Modifier + .size(TangemTheme.dimens.size16) + .align(Alignment.CenterVertically), color = TangemTheme.colors.icon.informative, + strokeWidth = TangemTheme.dimens.size2, ) Text( - text = "Fetching best rates ...", + text = stringResource(R.string.express_fetch_best_rates), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), @@ -370,7 +377,7 @@ private fun PermissionBadgeItem(modifier: Modifier = Modifier) { ), ) { Text( - text = "Permission required", + text = stringResource(id = R.string.express_provider_permission_needed), style = TangemTheme.typography.caption1, color = TangemTheme.colors.text.tertiary, modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6), @@ -404,7 +411,7 @@ private fun ProviderItem_Content_Preview() { iconUrl = "", subtitle = stringReference("1 000 000"), additionalBadge = ProviderState.AdditionalBadge.PermissionRequired, - percentLowerThenBest = -1.0f, + percentLowerThenBest = PercentLowerThanBest.Value(-1.0f), selectionType = ProviderState.SelectionType.SELECT, onProviderClick = {}, ) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 19cc389ae3..c3116dc8a3 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -50,7 +50,7 @@ internal class StateBuilder( return SwapStateHolder( blockchainId = networkInfo.blockchainId, sendCardData = SwapCardState.SwapCardData( - type = TransactionCardType.SendCard(actions.onAmountChanged, actions.onAmountSelected), + type = TransactionCardType.Inputtable(actions.onAmountChanged, actions.onAmountSelected), amountEquivalent = null, amountTextFieldValue = null, token = null, @@ -64,7 +64,7 @@ internal class StateBuilder( isBalanceHidden = true, ), receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReceiveCard(), + type = TransactionCardType.ReadOnly(), amountEquivalent = null, tokenIconUrl = "", tokenCurrency = "", @@ -79,14 +79,15 @@ internal class StateBuilder( ), fee = FeeItemState.Empty, networkCurrency = networkInfo.blockchainCurrency, - swapButton = SwapButton(enabled = false, loading = true, onClick = {}), + swapButton = SwapButton(enabled = false, onClick = {}), onRefresh = {}, onBackClicked = actions.onBackClicked, onChangeCardsClicked = actions.onChangeCardsClicked, onMaxAmountSelected = actions.onMaxAmountSelected, - updateInProgress = true, + changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, onShowPermissionBottomSheet = actions.openPermissionBottomSheet, providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty(), ) } @@ -97,10 +98,8 @@ internal class StateBuilder( if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), - amountTextFieldValue = TextFieldValue( - text = "0", - ), + type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), + amountTextFieldValue = null, amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", token = fromToken, tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, @@ -108,12 +107,12 @@ internal class StateBuilder( isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - balance = fromToken.getFormattedAmount(), + balance = fromToken.getFormattedAmount(isNeedSymbol = false), networkIconRes = getActiveIconRes(fromToken.currency.network.id.value), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.Empty( - type = TransactionCardType.ReceiveCard(), + type = TransactionCardType.ReadOnly(), amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", amountTextFieldValue = TextFieldValue( text = "0", @@ -135,10 +134,10 @@ internal class StateBuilder( fee = FeeItemState.Empty, swapButton = SwapButton( enabled = false, - loading = false, onClick = { }, ), - updateInProgress = false, + changeCardsButtonState = ChangeCardsButtonState.DISABLED, + priceImpact = PriceImpact.Empty(), ) } @@ -154,7 +153,7 @@ internal class StateBuilder( if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), + type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = null, token = uiStateHolder.sendCardData.token, @@ -168,7 +167,7 @@ internal class StateBuilder( isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReceiveCard(), + type = TransactionCardType.ReadOnly(), amountTextFieldValue = null, amountEquivalent = null, token = uiStateHolder.receiveCardData.token, @@ -183,10 +182,11 @@ internal class StateBuilder( ), warnings = emptyList(), fee = FeeItemState.Empty, - swapButton = SwapButton(enabled = false, loading = true, onClick = {}), + swapButton = SwapButton(enabled = false, onClick = {}), providerState = ProviderState.Loading(), permissionState = uiStateHolder.permissionState, - updateInProgress = true, + changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, + priceImpact = PriceImpact.Empty(), ) } @@ -205,18 +205,19 @@ internal class StateBuilder( fromToken: CryptoCurrency, swapProvider: SwapProvider, bestRatedProviderId: String, - isManyProviders: Boolean, + isNeedBestRateBadge: Boolean, selectedFeeType: FeeType, + isReverseSwapPossible: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder val warnings = getWarningsForSuccessState(quoteModel, fromToken) - val feeState = createFeeState(quoteModel.txFee, selectedFeeType) + val feeState = createFeeState(quoteModel.txFee, selectedFeeType, swapProvider) val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus return uiStateHolder.copy( sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), + type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat), token = fromCurrencyStatus, @@ -226,11 +227,11 @@ internal class StateBuilder( tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, networkIconRes = uiStateHolder.sendCardData.networkIconRes, - balance = fromCurrencyStatus.getFormattedAmount(), + balance = fromCurrencyStatus.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReceiveCard(), + type = TransactionCardType.ReadOnly(), amountTextFieldValue = TextFieldValue(quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation()), amountEquivalent = getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat), token = toCurrencyStatus, @@ -240,7 +241,7 @@ internal class StateBuilder( tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, networkIconRes = uiStateHolder.receiveCardData.networkIconRes, - balance = toCurrencyStatus.getFormattedAmount(), + balance = toCurrencyStatus.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), networkCurrency = quoteModel.networkCurrency, @@ -254,26 +255,56 @@ internal class StateBuilder( fee = feeState, swapButton = SwapButton( enabled = getSwapButtonEnabled(quoteModel.preparedSwapConfigState), - loading = false, onClick = actions.onSwapClick, ), - updateInProgress = false, + changeCardsButtonState = if (isReverseSwapPossible) { + ChangeCardsButtonState.ENABLED + } else { + ChangeCardsButtonState.DISABLED + }, providerState = swapProvider.convertToContentClickableProviderState( isBestRate = bestRatedProviderId == swapProvider.providerId, fromTokenInfo = quoteModel.fromTokenInfo, toTokenInfo = quoteModel.toTokenInfo, - isNeedBadge = isManyProviders, + isNeedBestRateBadge = isNeedBestRateBadge, selectionType = ProviderState.SelectionType.CLICK, onProviderClick = actions.onProviderClick, ), + priceImpact = if (quoteModel.priceImpact.value > PRICE_IMPACT_THRESHOLD) { + quoteModel.priceImpact + } else { + PriceImpact.Empty() + }, + tosState = createTosState(swapProvider), ) } + private fun createTosState(swapProvider: SwapProvider): TosState { + return TosState( + tosLink = swapProvider.termsOfUse?.let { + LegalState( + title = resourceReference(R.string.express_terms_of_use), + link = it, + onClick = actions.onTosClick, + ) + }, + policyLink = swapProvider.privacyPolicy?.let { + LegalState( + title = resourceReference(R.string.express_privacy_policy), + link = it, + onClick = actions.onPolicyClick, + ) + }, + ) + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") private fun getWarningsForSuccessState( quoteModel: SwapState.QuotesLoadedState, fromToken: CryptoCurrency, ): List { val warnings = mutableListOf() + addDomainWarnings(quoteModel, warnings) if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && quoteModel.preparedSwapConfigState.isFeeEnough && quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest @@ -293,8 +324,78 @@ internal class StateBuilder( ) else -> Unit } + addUnableCoverFeeWarning(quoteModel, fromToken, warnings) + // check isBalanceEnough, but for dex includeFeeInAmount always Excluded + if (!quoteModel.preparedSwapConfigState.isBalanceEnough && + quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included + ) { + warnings.add(SwapWarning.InsufficientFunds) + } + + val priceImpact = quoteModel.priceImpact + if (priceImpact is PriceImpact.ValueWithNotify && priceImpact.value > PRICE_IMPACT_THRESHOLD) { + warnings.add( + SwapWarning.HighPriceImpact( + priceImpact = priceImpact.getIntPercentValue(), + notificationConfig = highPriceImpactNotificationConfig(), + ), + ) + } + if (quoteModel.permissionState is PermissionDataState.PermissionLoading) { + warnings.add( + SwapWarning.TransactionInProgressWarning( + title = resourceReference(R.string.swapping_pending_transaction_title), + description = resourceReference(R.string.swapping_pending_transaction_subtitle), + ), + ) + } else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) { + warnings.add( + SwapWarning.TransactionInProgressWarning( + title = resourceReference(R.string.warning_express_active_transaction_title), + description = resourceReference( + id = R.string.warning_express_active_transaction_message, + formatArgs = wrappedList( + quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency.network.currencySymbol, + ), + ), + ), + ) + } + return warnings + } + + private fun addDomainWarnings(quoteModel: SwapState.QuotesLoadedState, warnings: MutableList) { + quoteModel.warnings.forEach { + when (it) { + is Warning.ExistentialDepositWarning -> { + warnings.add( + SwapWarning.GeneralInformational( + NotificationConfig( + title = resourceReference(R.string.warning_existential_deposit_title), + subtitle = resourceReference( + R.string.warning_existential_deposit_message, + wrappedList( + quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency.name, + it.existentialDeposit.toPlainString(), + ), + ), + iconResId = R.drawable.ic_alert_circle_24, + ), + ), + ) + } + } + } + } + + private fun addUnableCoverFeeWarning( + quoteModel: SwapState.QuotesLoadedState, + fromToken: CryptoCurrency, + warnings: MutableList, + ) { if (!quoteModel.preparedSwapConfigState.isFeeEnough && - quoteModel.preparedSwapConfigState.isBalanceEnough + quoteModel.preparedSwapConfigState.isBalanceEnough && + quoteModel.permissionState !is PermissionDataState.PermissionLoading ) { warnings.add( SwapWarning.UnableToCoverFeeWarning( @@ -305,25 +406,10 @@ internal class StateBuilder( ), ) } - // check isBalanceEnough, but for dex includeFeeInAmount always Excluded - if (!quoteModel.preparedSwapConfigState.isBalanceEnough && - quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included - ) { - warnings.add(SwapWarning.InsufficientFunds) - } - - if (quoteModel.priceImpact > PRICE_IMPACT_THRESHOLD) { - warnings.add( - SwapWarning.HighPriceImpact( - priceImpact = (quoteModel.priceImpact * HUNDRED_PERCENTS).toInt(), - notificationConfig = highPriceImpactNotificationConfig(), - ), - ) - } - return warnings } private fun getSwapButtonEnabled(preparedSwapConfigState: PreparedSwapConfigState): Boolean { + if (preparedSwapConfigState.hasOutgoingTransaction) return false return when (preparedSwapConfigState.includeFeeInAmount) { IncludeFeeInAmount.BalanceNotEnough -> false IncludeFeeInAmount.Excluded -> @@ -334,16 +420,27 @@ internal class StateBuilder( } } + @Suppress("LongParameterList") fun createQuotesErrorState( uiStateHolder: SwapStateHolder, swapProvider: SwapProvider, fromToken: TokenSwapInfo, toToken: CryptoCurrencyStatus?, + includeFeeInAmount: IncludeFeeInAmount, dataError: DataError, + isReverseSwapPossible: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder - val warning = getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency) + val warnings = mutableListOf() + warnings.add(getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency)) + if (includeFeeInAmount is IncludeFeeInAmount.Included) { + warnings.add( + SwapWarning.GeneralWarning( + createNetworkFeeCoverageNotificationConfig(), + ), + ) + } val providerState = getProviderStateForError( swapProvider = swapProvider, fromToken = fromToken.cryptoCurrencyStatus.currency, @@ -353,7 +450,7 @@ internal class StateBuilder( ) val receiveCardData = toToken?.let { SwapCardState.SwapCardData( - type = TransactionCardType.ReceiveCard(), + type = TransactionCardType.ReadOnly(), amountTextFieldValue = TextFieldValue( text = "0", ), @@ -365,11 +462,11 @@ internal class StateBuilder( tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, networkIconRes = uiStateHolder.receiveCardData.networkIconRes, - balance = toToken.getFormattedAmount(), + balance = toToken.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ) } ?: SwapCardState.Empty( - type = TransactionCardType.ReceiveCard(), + type = TransactionCardType.ReadOnly(), amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", amountTextFieldValue = TextFieldValue( text = "0", @@ -379,18 +476,24 @@ internal class StateBuilder( return uiStateHolder.copy( sendCardData = uiStateHolder.sendCardData.copy( amountEquivalent = getFormattedFiatAmount(fromToken.amountFiat), + balance = fromToken.cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), ), receiveCardData = receiveCardData, - warnings = listOf(warning), + warnings = warnings, permissionState = SwapPermissionState.Empty, fee = FeeItemState.Empty, swapButton = SwapButton( enabled = false, - loading = false, onClick = actions.onSwapClick, ), - updateInProgress = false, + changeCardsButtonState = if (isReverseSwapPossible) { + ChangeCardsButtonState.ENABLED + } else { + ChangeCardsButtonState.DISABLED + }, providerState = providerState, + priceImpact = PriceImpact.Empty(), + tosState = createTosState(swapProvider), ) } @@ -430,10 +533,18 @@ internal class StateBuilder( iconResId = R.drawable.ic_alert_circle_24, ), ) - is DataError.UnknownError -> SwapWarning.GeneralWarning( + else -> SwapWarning.GeneralWarning( notificationConfig = NotificationConfig( - title = resourceReference(R.string.common_error), - subtitle = resourceReference(R.string.swapping_generic_error), + title = if (dataError is DataError.UnknownError) { + resourceReference(R.string.common_error) + } else { + resourceReference(R.string.warning_express_refresh_required_title) + }, + subtitle = if (dataError is DataError.UnknownError) { + resourceReference(R.string.swapping_generic_error) + } else { + resourceReference(R.string.generic_error_code, wrappedList(dataError.code.toString())) + }, iconResId = R.drawable.img_attention_20, buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.warning_button_refresh), @@ -441,25 +552,19 @@ internal class StateBuilder( ), ), ) - else -> SwapWarning.GeneralWarning( - notificationConfig = NotificationConfig( - title = resourceReference(R.string.common_error), - subtitle = resourceReference(R.string.generic_error_code, wrappedList(dataError.code.toString())), - iconResId = R.drawable.img_attention_20, - ), - ) } } fun createQuotesEmptyAmountState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, + isReverseSwapPossible: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), + type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = emptyAmountState.zeroAmountEquivalent, token = uiStateHolder.sendCardData.token, @@ -473,7 +578,7 @@ internal class StateBuilder( isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReceiveCard(), + type = TransactionCardType.ReadOnly(), amountTextFieldValue = TextFieldValue("0"), amountEquivalent = emptyAmountState.zeroAmountEquivalent, token = uiStateHolder.receiveCardData.token, @@ -490,34 +595,44 @@ internal class StateBuilder( fee = FeeItemState.Empty, swapButton = SwapButton( enabled = false, - loading = false, onClick = { }, ), - updateInProgress = false, + changeCardsButtonState = if (isReverseSwapPossible) { + ChangeCardsButtonState.ENABLED + } else { + ChangeCardsButtonState.DISABLED + }, providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty(), ) } fun createSwapInProgressState(uiState: SwapStateHolder): SwapStateHolder { return uiState.copy( swapButton = uiState.swapButton.copy( - loading = true, enabled = false, ), ) } - fun addTokensToState(uiState: SwapStateHolder, tokensDataState: CurrenciesGroup): SwapStateHolder { + fun addTokensToState( + uiState: SwapStateHolder, + fromToken: CryptoCurrency, + tokensDataState: CurrenciesGroup, + ): SwapStateHolder { return uiState.copy( selectTokenState = tokensDataConverter.convert( - value = tokensDataState, + value = CurrenciesGroupWithFromCurrency( + fromCurrency = fromToken, + group = tokensDataState, + ), ), ) } fun createSilentLoadState(uiState: SwapStateHolder): SwapStateHolder { return uiState.copy( - updateInProgress = true, + changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, ) } @@ -579,14 +694,14 @@ internal class StateBuilder( } } - fun createInitialErrorState(uiState: SwapStateHolder, onRefreshClick: () -> Unit): SwapStateHolder { + fun createInitialErrorState(uiState: SwapStateHolder, code: Int, onRefreshClick: () -> Unit): SwapStateHolder { return uiState.copy( warnings = listOf( SwapWarning.GeneralWarning( notificationConfig = NotificationConfig( title = TextReference.Res(R.string.warning_express_refresh_required_title), - subtitle = TextReference.EMPTY, - iconResId = R.drawable.ic_alert_circle_24, + subtitle = TextReference.Res(R.string.generic_error_code, wrappedList(code)), + iconResId = R.drawable.ic_alert_triangle_20, buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( text = TextReference.Res(R.string.warning_button_refresh), onClick = onRefreshClick, @@ -597,7 +712,7 @@ internal class StateBuilder( ) } - private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState { + private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType, swapProvider: SwapProvider): FeeItemState { val isClickable: Boolean val fee = when (txFeeState) { TxFeeState.Empty -> return FeeItemState.Empty @@ -623,6 +738,11 @@ internal class StateBuilder( title = resourceReference(R.string.common_fee_label), amountCrypto = fee.feeCryptoFormatted, symbolCrypto = fee.cryptoSymbol, + explanation = if (swapProvider.type == ExchangeProviderType.CEX) { + resourceReference(R.string.express_cex_fee_explanation) + } else { + null + }, amountFiatFormatted = fee.feeFiatFormatted, isClickable = isClickable, onClick = actions.onClickFee, @@ -630,36 +750,42 @@ internal class StateBuilder( } fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder { + val warnings = uiState.warnings.filterNot { it is SwapWarning.PermissionNeeded }.toMutableList() + warnings.add( + 0, + SwapWarning.TransactionInProgressWarning( + title = resourceReference(R.string.swapping_pending_transaction_title), + description = resourceReference(R.string.swapping_pending_transaction_subtitle), + ), + ) return uiState.copy( permissionState = SwapPermissionState.InProgress, - warnings = uiState.warnings.filterNot { it is SwapWarning.PermissionNeeded }, + warnings = warnings, ) } @Suppress("LongParameterList") fun createSuccessState( uiState: SwapStateHolder, - timeStamp: Long, - txUrl: String, + txState: TxState.TxSent, dataState: SwapProcessDataState, onExploreClick: () -> Unit, onStatusClick: () -> Unit, + txUrl: String, ): SwapStateHolder { val fee = requireNotNull(dataState.selectedFee) val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) - val fromAmount = requireNotNull(dataState.amount?.toBigDecimal()) - val toAmount = requireNotNull(dataState.swapDataModel?.toTokenAmount?.value) + val fromAmount = txState.fromAmountValue ?: BigDecimal.ZERO + val toAmount = txState.toAmountValue ?: BigDecimal.ZERO val providerState = uiState.providerState as ProviderState.Content - val fromCryptoAmount = BigDecimalFormatter.formatCryptoAmount(fromAmount, fromCryptoCurrency.currency) - val toCryptoAmount = BigDecimalFormatter.formatCryptoAmount(toAmount, toCryptoCurrency.currency) val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) return uiState.copy( successState = SwapSuccessStateHolder( - timestamp = timeStamp, + timestamp = txState.timestamp, txUrl = txUrl, providerName = stringReference(providerState.name), providerType = stringReference(providerState.type), @@ -667,8 +793,8 @@ internal class StateBuilder( providerIcon = providerState.iconUrl, rate = providerState.subtitle, fee = stringReference("${fee.feeCryptoFormatted} (${fee.feeFiatFormatted})"), - fromTokenAmount = stringReference(fromCryptoAmount), - toTokenAmount = stringReference(toCryptoAmount), + fromTokenAmount = stringReference(txState.fromAmount.orEmpty()), + toTokenAmount = stringReference(txState.toAmount.orEmpty()), fromTokenFiatAmount = stringReference(fromFiatAmount), toTokenFiatAmount = stringReference(toFiatAmount), fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency), @@ -686,7 +812,7 @@ internal class StateBuilder( onClick = onAlertClick, type = if (txState is TxState.NetworkError) GenericWarningType.NETWORK else GenericWarningType.OTHER, ), - updateInProgress = false, + changeCardsButtonState = ChangeCardsButtonState.ENABLED, ) } @@ -761,6 +887,17 @@ internal class StateBuilder( } } + fun showWebViewBottomSheet(uiState: SwapStateHolder, url: String, onDismiss: () -> Unit): SwapStateHolder { + val config = WebViewBottomSheetConfig(url = url) + return uiState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = config, + ), + ) + } + fun showPermissionBottomSheet(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder { val permissionState = uiState.permissionState if (permissionState is SwapPermissionState.ReadyForRequest) { @@ -789,7 +926,7 @@ internal class StateBuilder( fun showSelectProviderBottomSheet( uiState: SwapStateHolder, selectedProviderId: String, - pricesLowerBest: Map, + pricesLowerBest: Map, providersStates: Map, unavailableProviders: List, onDismiss: () -> Unit, @@ -820,6 +957,7 @@ internal class StateBuilder( fun updateProvidersBottomSheetContent( uiState: SwapStateHolder, + pricesLowerBest: Map, tokenSwapInfoForProviders: Map, ): SwapStateHolder { val config = uiState.bottomSheetConfig?.content as? ChooseProviderBottomSheetConfig @@ -835,6 +973,9 @@ internal class StateBuilder( .getFormattedCryptoAmount(tokenInfo.cryptoCurrencyStatus.currency) it.copy( subtitle = stringReference(rateString), + percentLowerThenBest = pricesLowerBest[it.id]?.let { percent -> + PercentLowerThanBest.Value(percent) + } ?: PercentLowerThanBest.Empty, ) } else { it @@ -912,6 +1053,7 @@ internal class StateBuilder( amountCrypto = this.normalFee.feeCryptoFormatted, symbolCrypto = this.normalFee.cryptoSymbol, amountFiatFormatted = this.normalFee.feeFiatFormatted, + explanation = null, isClickable = true, onClick = {}, ), @@ -921,6 +1063,7 @@ internal class StateBuilder( amountCrypto = this.priorityFee.feeCryptoFormatted, symbolCrypto = this.priorityFee.cryptoSymbol, amountFiatFormatted = this.priorityFee.feeFiatFormatted, + explanation = null, isClickable = true, onClick = {}, ), @@ -928,7 +1071,7 @@ internal class StateBuilder( } private fun Map.Entry.convertToProviderBottomSheetState( - pricesLowerBest: Map, + pricesLowerBest: Map, onProviderSelect: (String) -> Unit, ): ProviderState? { val provider = this.key @@ -954,7 +1097,7 @@ internal class StateBuilder( // region warnings private fun createPermissionNotificationConfig(fromTokenSymbol: String): NotificationConfig { return NotificationConfig( - title = resourceReference(R.string.swapping_permission_header), + title = resourceReference(R.string.express_provider_permission_needed), subtitle = resourceReference( id = R.string.swapping_permission_subheader, formatArgs = wrappedList(fromTokenSymbol), @@ -1017,7 +1160,7 @@ internal class StateBuilder( fromTokenInfo: TokenSwapInfo, toTokenInfo: TokenSwapInfo, selectionType: ProviderState.SelectionType, - isNeedBadge: Boolean, + isNeedBestRateBadge: Boolean, onProviderClick: (String) -> Unit, ): ProviderState { val rate = toTokenInfo.tokenAmount.value.calculateRate( @@ -1027,7 +1170,7 @@ internal class StateBuilder( val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol val rateString = "1 $fromCurrencySymbol ≈ $rate $toCurrencySymbol" - val badge = if (isNeedBadge && isBestRate) { + val badge = if (isNeedBestRateBadge && isBestRate) { ProviderState.AdditionalBadge.BestTrade } else { ProviderState.AdditionalBadge.Empty @@ -1040,7 +1183,7 @@ internal class StateBuilder( subtitle = stringReference(rateString), additionalBadge = badge, selectionType = selectionType, - percentLowerThenBest = ZERO_PERCENT, + percentLowerThenBest = PercentLowerThanBest.Empty, onProviderClick = onProviderClick, ) } @@ -1049,7 +1192,7 @@ internal class StateBuilder( isBestRate: Boolean, state: SwapState.QuotesLoadedState, selectionType: ProviderState.SelectionType, - pricesLowerBest: Map, + pricesLowerBest: Map, onProviderClick: (String) -> Unit, ): ProviderState { val toTokenInfo = state.toTokenInfo @@ -1069,7 +1212,9 @@ internal class StateBuilder( subtitle = stringReference(rateString), additionalBadge = additionalBadge, selectionType = selectionType, - percentLowerThenBest = pricesLowerBest[this] ?: ZERO_PERCENT, + percentLowerThenBest = pricesLowerBest[this.providerId]?.let { percent -> + PercentLowerThanBest.Value(percent) + } ?: PercentLowerThanBest.Value(0f), onProviderClick = onProviderClick, ) } @@ -1103,15 +1248,15 @@ internal class StateBuilder( selectionType = selectionType, subtitle = alertText, additionalBadge = ProviderState.AdditionalBadge.Empty, - percentLowerThenBest = ZERO_PERCENT, + percentLowerThenBest = PercentLowerThanBest.Empty, onProviderClick = onProviderClick, ) } - private fun CryptoCurrencyStatus.getFormattedAmount(): String { + private fun CryptoCurrencyStatus.getFormattedAmount(isNeedSymbol: Boolean): String { val amount = value.amount ?: return UNKNOWN_AMOUNT_SIGN - - return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals) + val symbol = if (isNeedSymbol) currency.symbol else "" + return BigDecimalFormatter.formatCryptoAmount(amount, symbol, currency.decimals) } @Suppress("UnusedPrivateMember") @@ -1129,21 +1274,23 @@ internal class StateBuilder( } private fun SwapAmount.getFormattedCryptoAmount(token: CryptoCurrency): String { - return "${this.formatToUIRepresentation()} ${token.network.currencySymbol}" + return "${this.formatToUIRepresentation()} ${token.symbol}" } private fun BigDecimal.calculateRate(to: BigDecimal, decimals: Int): BigDecimal { return this.divide(to, min(decimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP) } + private fun toBigDecimalOrNull(text: String): BigDecimal? { + return text.replace(",", ".").toBigDecimalOrNull() + } + private companion object { const val ADDRESS_MIN_LENGTH = 11 const val ADDRESS_FIRST_PART_LENGTH = 7 const val ADDRESS_SECOND_PART_LENGTH = 4 private const val PRICE_IMPACT_THRESHOLD = 0.1 - private const val HUNDRED_PERCENTS = 100 private const val UNKNOWN_AMOUNT_SIGN = "—" private const val MAX_DECIMALS_TO_SHOW = 8 - private const val ZERO_PERCENT = 0f } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index 1ee50c9604..04a53a581c 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -1,21 +1,34 @@ package com.tangem.feature.swap.ui import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.swap.models.SwapStateHolder import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig +import com.tangem.feature.swap.models.states.WebViewBottomSheetConfig +import com.tangem.feature.swap.presentation.R @Composable internal fun SwapScreen(stateHolder: SwapStateHolder) { BackHandler(onBack = stateHolder.onBackClicked) Scaffold( + modifier = Modifier.systemBarsPadding(), + topBar = { + AppBarWithBackButton( + text = stringResource(R.string.common_swap), + onBackClick = stateHolder.onBackClicked, + iconRes = R.drawable.ic_close_24, + ) + }, + contentWindowInsets = WindowInsets(left = 0, top = 0, right = 0, bottom = 0), containerColor = TangemTheme.colors.background.secondary, ) { scaffoldPaddings -> @@ -35,6 +48,9 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) { is ChooseFeeBottomSheetConfig -> { ChooseFeeBottomSheet(config = config) } + is WebViewBottomSheetConfig -> { + WebViewBottomSheet(config = config) + } } } } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 949ead0f64..cb4d02dbf9 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -1,9 +1,9 @@ package com.tangem.feature.swap.ui -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -11,19 +11,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.constraintlayout.compose.ConstraintLayout import com.tangem.common.Strings.STARS import com.tangem.core.ui.components.* -import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.getActiveIconResByCoinId +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.* import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState @@ -39,70 +44,53 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi .fillMaxSize() .background(color = TangemTheme.colors.background.secondary), ) { - Column { - AppBarWithBackButton( - text = stringResource(R.string.common_swap), - onBackClick = state.onBackClicked, - iconRes = R.drawable.ic_close_24, + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing32, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + MainInfo(state) + + ProviderItemBlock( + state = state.providerState, + modifier = Modifier + .clickable( + enabled = state.providerState.onProviderClick != null, + onClick = { state.providerState.onProviderClick?.invoke(state.providerState.id) }, + ), ) - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - top = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing32, - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), - ) { - MainInfo(state) + FeeItemBlock(state = state.fee) - ProviderItemBlock( - state = state.providerState, + if (state.warnings.isNotEmpty()) SwapWarnings(warnings = state.warnings) + + MainButton(state = state, onPermissionWarningClick = state.onShowPermissionBottomSheet) + + state.tosState?.let { + ProviderTos( + tosState = it, modifier = Modifier - .clickable( - enabled = state.providerState.onProviderClick != null, - onClick = { state.providerState.onProviderClick?.invoke(state.providerState.id) }, - ), + .padding(top = TangemTheme.dimens.spacing16), ) - - FeeItemBlock(state = state.fee) - - if (state.warnings.isNotEmpty()) SwapWarnings(warnings = state.warnings) - - AnimatedVisibility(visible = state.permissionState is SwapPermissionState.InProgress) { - CardWithIcon( - title = stringResource(id = R.string.swapping_pending_transaction_title), - description = stringResource(id = R.string.swapping_pending_transaction_subtitle), - icon = { - CircularProgressIndicator( - modifier = Modifier - .size(TangemTheme.dimens.size16), - color = TangemTheme.colors.icon.primary1, - strokeWidth = TangemTheme.dimens.size2, - ) - }, - ) - } - MainButton(state = state, onPermissionWarningClick = state.onShowPermissionBottomSheet) } } - AnimatedVisibility( - visible = keyboard is Keyboard.Opened, - modifier = Modifier - .imePadding() - .align(Alignment.BottomCenter), - ) { + if (keyboard is Keyboard.Opened) { Text( text = stringResource(id = R.string.send_max_amount_label), style = TangemTheme.typography.button, color = TangemTheme.colors.text.primary1, modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding() .fillMaxWidth() .background(TangemTheme.colors.button.secondary) .clickable { state.onMaxAmountSelected?.invoke() } @@ -134,25 +122,25 @@ private fun MainInfo(state: SwapStateHolder) { modifier = Modifier.fillMaxWidth(), ) { val (topCard, bottomCard, button) = createRefs() - val priceImpactWarning = state.warnings.filterIsInstance().firstOrNull() + val priceImpact = state.priceImpact TransactionCardData( - priceImpactWarning = priceImpactWarning, + priceImpact = priceImpact, swapCardState = state.sendCardData, modifier = Modifier.constrainAs(topCard) { top.linkTo(parent.top) }, onSelectTokenClick = state.onSelectTokenClick, ) - val marginCard = TangemTheme.dimens.spacing16 + val marginCard = TangemTheme.dimens.spacing12 TransactionCardData( - priceImpactWarning = priceImpactWarning, + priceImpact = priceImpact, swapCardState = state.receiveCardData, modifier = Modifier.constrainAs(bottomCard) { top.linkTo(topCard.bottom, margin = marginCard) }, onSelectTokenClick = state.onSelectTokenClick, ) - val marginButton = TangemTheme.dimens.spacing32 + val marginButton = TangemTheme.dimens.spacing30 SwapButton( state, modifier = Modifier.constrainAs(button) { @@ -166,7 +154,7 @@ private fun MainInfo(state: SwapStateHolder) { @Composable private fun TransactionCardData( - priceImpactWarning: SwapWarning.HighPriceImpact?, + priceImpact: PriceImpact, swapCardState: SwapCardState, onSelectTokenClick: (() -> Unit)?, modifier: Modifier = Modifier, @@ -193,7 +181,7 @@ private fun TransactionCardData( amountEquivalent = swapCardState.amountEquivalent, tokenIconUrl = swapCardState.tokenIconUrl ?: "", tokenCurrency = swapCardState.tokenCurrency, - priceImpact = priceImpactWarning, + priceImpact = priceImpact, networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null, iconPlaceholder = swapCardState.coinId?.let { getActiveIconResByCoinId(it) @@ -205,6 +193,84 @@ private fun TransactionCardData( } } +@Composable +private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) { + val tos = tosState.tosLink + val policy = tosState.policyLink + if (tos == null && policy == null) return + + val (annotatedString, click) = getAnnotatedStringForLegalsWithClick(tos, policy) + + ClickableText( + text = annotatedString, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing54), + style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Center), + onClick = click, + ) +} + +@Composable +private fun getAnnotatedStringForLegalsWithClick( + tos: LegalState?, + policy: LegalState?, +): Pair Unit> { + return if (tos != null && policy != null) { + val tosTitle = tos.title.resolveReference() + val policyTitle = policy.title.resolveReference() + val fullString = stringResource(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle) + val tosIndex = fullString.indexOf(tosTitle) + val policyIndex = fullString.indexOf(policyTitle) + val string = buildAnnotatedString { + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(fullString.substring(0, tosIndex)) + } + withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { + append(fullString.substring(tosIndex, tosIndex + tosTitle.length)) + } + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(fullString.substring(tosIndex + tosTitle.length, policyIndex)) + } + withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { + append(fullString.substring(policyIndex, policyIndex + policyTitle.length)) + } + } + val click = { i: Int -> + val tosStyle = requireNotNull(string.spanStyles.getOrNull(1)) + if (i in tosStyle.start..tosStyle.end) { + tos.onClick(tos.link) + } + val policyStyle = requireNotNull(string.spanStyles.lastOrNull()) + if (i in policyStyle.start..policyStyle.end) { + policy.onClick(policy.link) + } + } + string to click + } else { + val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" } + val legalTitle = legal.title + .resolveReference() + val fullString = stringResource(id = R.string.express_legal_one_placeholder, legal) + val legalIndex = fullString.indexOf(legalTitle) + val string = buildAnnotatedString { + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(fullString.substring(0, legalIndex)) + } + withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { + append(fullString.substring(legalIndex, legalIndex + legalTitle.length)) + } + } + val click = { i: Int -> + val legalStyle = requireNotNull(string.spanStyles.lastOrNull()) + if (i in legalStyle.start..legalStyle.end) { + legal.onClick(legal.link) + } + } + string to click + } +} + @OptIn(ExperimentalMaterialApi::class) @Composable private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) { @@ -215,28 +281,39 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) { contentColor = TangemTheme.colors.text.primary1, modifier = modifier.size(TangemTheme.dimens.size48), onClick = state.onChangeCardsClicked, - enabled = !state.updateInProgress, + enabled = state.changeCardsButtonState == ChangeCardsButtonState.ENABLED, ) { - if (state.updateInProgress) { - CircularProgressIndicator( - modifier = Modifier - .size(TangemTheme.dimens.size16) - .padding(TangemTheme.dimens.spacing14), - color = TangemTheme.colors.icon.primary1, - strokeWidth = TangemTheme.dimens.size2, - ) - } else { - Icon( - painter = painterResource(id = R.drawable.ic_exchange_vertical_24), - contentDescription = null, - tint = TangemTheme.colors.text.primary1, - modifier = Modifier.padding(TangemTheme.dimens.spacing12), - ) + when (state.changeCardsButtonState) { + ChangeCardsButtonState.UPDATE_IN_PROGRESS -> { + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size16) + .padding(TangemTheme.dimens.spacing14), + color = TangemTheme.colors.icon.primary1, + strokeWidth = TangemTheme.dimens.size2, + ) + } + ChangeCardsButtonState.ENABLED -> { + Icon( + painter = painterResource(id = R.drawable.ic_exchange_vertical_24), + contentDescription = null, + tint = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) + } + ChangeCardsButtonState.DISABLED -> { + Icon( + painter = painterResource(id = R.drawable.ic_exchange_vertical_24), + contentDescription = null, + tint = TangemTheme.colors.text.disabled, + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) + } } } } -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable private fun SwapWarnings(warnings: List) { Column( @@ -292,6 +369,26 @@ private fun SwapWarnings(warnings: List) { config = warning.notificationConfig, ) } + is SwapWarning.GeneralInformational -> { + Notification( + config = warning.notificationConfig, + iconTint = TangemTheme.colors.icon.accent, + ) + } + is SwapWarning.TransactionInProgressWarning -> { + CardWithIcon( + title = warning.title.resolveReference(), + description = warning.description.resolveReference(), + icon = { + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size16), + color = TangemTheme.colors.icon.primary1, + strokeWidth = TangemTheme.dimens.size2, + ) + }, + ) + } else -> {} } SpacerH8() @@ -308,7 +405,6 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.swapping_insufficient_funds), enabled = false, - showProgress = state.swapButton.loading, onClick = state.swapButton.onClick, ) } @@ -317,17 +413,15 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U modifier = Modifier.fillMaxWidth(), text = stringResource(id = R.string.swapping_give_permission), enabled = true, - showProgress = state.swapButton.loading, onClick = onPermissionWarningClick, ) } else -> { PrimaryButtonIconEnd( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.common_swap), + text = stringResource(id = R.string.swapping_swap_action), iconResId = R.drawable.ic_tangem_24, enabled = state.swapButton.enabled, - showProgress = state.swapButton.loading, onClick = state.swapButton.onClick, ) } @@ -337,7 +431,7 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U // region preview private val sendCard = SwapCardState.SwapCardData( - type = TransactionCardType.SendCard({}) {}, + type = TransactionCardType.Inputtable({}, {}), amountTextFieldValue = TextFieldValue(), amountEquivalent = "1 000 000", tokenIconUrl = "", @@ -352,7 +446,7 @@ private val sendCard = SwapCardState.SwapCardData( ) private val receiveCard = SwapCardState.SwapCardData( - type = TransactionCardType.ReceiveCard(), + type = TransactionCardType.ReadOnly(), amountTextFieldValue = TextFieldValue(), amountEquivalent = "1 000 000", tokenIconUrl = "", @@ -375,13 +469,14 @@ private val state = SwapStateHolder( amountCrypto = "100", symbolCrypto = "1000", amountFiatFormatted = "(100)", + explanation = null, isClickable = true, onClick = {}, ), warnings = listOf( SwapWarning.PermissionNeeded( notificationConfig = NotificationConfig( - title = stringReference("Give Premission"), + title = stringReference("Give Permission"), subtitle = stringReference("To continue swapping you need to give permission to Tangem"), iconResId = R.drawable.ic_locked_24, ), @@ -395,13 +490,26 @@ private val state = SwapStateHolder( ), ), networkCurrency = "MATIC", - swapButton = SwapButton(enabled = true, loading = false, onClick = {}), + swapButton = SwapButton(enabled = true, onClick = {}), onRefresh = {}, onBackClicked = {}, onChangeCardsClicked = {}, permissionState = SwapPermissionState.InProgress, blockchainId = "POLYGON", providerState = ProviderState.Loading(), + priceImpact = PriceImpact.Empty(), + tosState = TosState( + tosLink = LegalState( + title = stringReference("Terms of Use"), + link = "https://tangem.com", + onClick = {}, + ), + policyLink = LegalState( + title = stringReference("Privacy Policy"), + link = "https://tangem.com", + onClick = {}, + ), + ), ) @Preview diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 82b05a7c86..930039d697 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -42,10 +42,16 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) .background(color = TangemTheme.colors.background.secondary), content = { padding -> val modifier = Modifier.padding(padding) - if (state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty()) { - EmptyTokensList(modifier) - } else { - ListOfTokens(state = state, modifier = modifier) + when { + state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && state.afterSearch -> { + TokensNotFound(modifier) + } + state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && !state.afterSearch -> { + EmptyTokensList(modifier) + } + else -> { + ListOfTokens(state = state, modifier = modifier) + } } }, topBar = { @@ -91,17 +97,36 @@ private fun EmptyTokensList(modifier: Modifier = Modifier) { } } +@Composable +private fun TokensNotFound(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillMaxSize(), + ) { + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing32) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.TopCenter), + text = stringResource(id = R.string.express_token_list_empty_search), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } +} + @Composable private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) { val screenBackgroundColor = TangemTheme.colors.background.secondary LazyColumn( modifier = modifier .background(color = screenBackgroundColor) - .fillMaxSize(), + .fillMaxSize() + .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - item { SpacerH8() } - tokensToSelectItems(state.availableTokens, state.onTokenSelected) item { SpacerH12() } @@ -155,6 +180,7 @@ private fun TitleHeader(item: TokenToSelectState.Title, modifier: Modifier = Mod Text( text = item.title.resolveReference().uppercase(), style = TangemTheme.typography.overline, + color = TangemTheme.colors.text.tertiary, modifier = Modifier .padding( top = TangemTheme.dimens.spacing16, @@ -282,6 +308,7 @@ private fun TokenScreenPreview() { state = SwapSelectTokenStateHolder( availableTokens = listOf(title, token, token, token).toImmutableList(), unavailableTokens = listOf(title, token, token, token).toImmutableList(), + afterSearch = false, onSearchEntered = {}, onTokenSelected = {}, ), diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index d540514a81..902a000aef 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -60,23 +60,25 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad TransactionDoneTitle(titleRes = R.string.swapping_success_view_title, date = state.timestamp) SpacerH16() InputRowImage( - title = TextReference.Res(R.string.swapping_success_from_title), + title = TextReference.Res(R.string.swapping_from_title), subtitle = state.fromTokenAmount, caption = state.fromTokenFiatAmount, tokenIconState = state.fromTokenIconState ?: TokenIconState.Loading, modifier = Modifier .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action), + showNetworkIcon = true, ) SpacerH16() InputRowImage( - title = TextReference.Res(R.string.swapping_success_to_title), + title = TextReference.Res(R.string.swapping_to_title), subtitle = state.toTokenAmount, caption = state.toTokenFiatAmount, tokenIconState = state.toTokenIconState ?: TokenIconState.Loading, modifier = Modifier .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action), + showNetworkIcon = true, ) SpacerH16() InputRowBestRate( diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index b16a5e4c21..45d37e07de 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -37,7 +37,7 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.ImageBackgroundContrastChecker -import com.tangem.feature.swap.models.SwapWarning +import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.TransactionCardType import kotlinx.coroutines.launch @@ -49,7 +49,7 @@ fun TransactionCard( tokenIconUrl: String, tokenCurrency: String, amountEquivalent: String?, - priceImpact: SwapWarning.HighPriceImpact?, + priceImpact: PriceImpact, textFieldValue: TextFieldValue?, modifier: Modifier = Modifier, @DrawableRes iconPlaceholder: Int? = null, @@ -138,7 +138,7 @@ fun TransactionCardEmpty( type = type, amountEquivalent = amountEquivalent, textFieldValue = textFieldValue, - priceImpact = null, + priceImpact = PriceImpact.Empty(), ) } @@ -182,16 +182,15 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - val title = when (type) { - is TransactionCardType.ReceiveCard -> R.string.exchange_receive_view_header - is TransactionCardType.SendCard -> R.string.exchange_send_view_header - } + val title = type.headerResId Text( text = stringResource(id = title), color = TangemTheme.colors.text.tertiary, maxLines = 1, style = MaterialTheme.typography.subtitle2, - modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size24), + modifier = Modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size24) + .align(Alignment.CenterVertically), ) SpacerW16() if (balance.isNotBlank()) { @@ -201,8 +200,8 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie color = TangemTheme.colors.text.tertiary, style = MaterialTheme.typography.body2, modifier = Modifier - .defaultMinSize(minHeight = TangemTheme.dimens.size20) - .padding(top = TangemTheme.dimens.spacing2), + .defaultMinSize(minHeight = TangemTheme.dimens.size24) + .align(Alignment.CenterVertically), ) } } else { @@ -221,7 +220,7 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie private fun Content( type: TransactionCardType, amountEquivalent: String?, - priceImpact: SwapWarning.HighPriceImpact?, + priceImpact: PriceImpact, textFieldValue: TextFieldValue?, ) { Row( @@ -243,7 +242,7 @@ private fun Content( ) { val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32) when (type) { - is TransactionCardType.ReceiveCard -> { + is TransactionCardType.ReadOnly -> { if (textFieldValue != null) { ResizableText( text = textFieldValue.text, @@ -261,7 +260,7 @@ private fun Content( ) } } - is TransactionCardType.SendCard -> { + is TransactionCardType.Inputtable -> { AutoSizeTextField( modifier = sumTextModifier, textFieldValue = textFieldValue ?: TextFieldValue(), @@ -274,10 +273,10 @@ private fun Content( SpacerH8() if (amountEquivalent != null) { - if (type is TransactionCardType.ReceiveCard && priceImpact != null) { + if (type is TransactionCardType.ReadOnly && priceImpact !is PriceImpact.Empty) { Row { Text( - text = makePriceImpactBalanceWarning(amountEquivalent, priceImpact.priceImpact), + text = makePriceImpactBalanceWarning(amountEquivalent, priceImpact.getIntPercentValue()), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, modifier = Modifier @@ -419,7 +418,7 @@ private fun TokenIcon( contentAlignment = Alignment.Center, ) { Image( - modifier = Modifier.padding(all = TangemTheme.dimens.spacing0_5), + modifier = Modifier.padding(all = TangemTheme.dimens.spacing2), painter = painterResource(id = networkIconRes), contentDescription = null, ) @@ -483,7 +482,7 @@ private fun Preview_SwapMainCard_InDarkTheme() { @Composable private fun TransactionCardPreview() { TransactionCard( - type = TransactionCardType.SendCard({}) {}, + type = TransactionCardType.Inputtable({}, {}), amountEquivalent = "1 000 000", tokenIconUrl = "", tokenCurrency = "DAI", @@ -491,7 +490,7 @@ private fun TransactionCardPreview() { onChangeTokenClick = {}, balance = "123", textFieldValue = TextFieldValue(), - priceImpact = null, + priceImpact = PriceImpact.Empty(), ) } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/WebViewBottomSheet.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/WebViewBottomSheet.kt new file mode 100644 index 0000000000..eb2df9fe19 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/WebViewBottomSheet.kt @@ -0,0 +1,79 @@ +package com.tangem.feature.swap.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import com.google.accompanist.web.WebView +import com.google.accompanist.web.rememberWebViewState +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.models.states.WebViewBottomSheetConfig + +@Composable +fun WebViewBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + ) { content: WebViewBottomSheetConfig -> + WebViewBottomSheetContent( + WebViewBottomSheetConfig(url = content.url), + ) + } +} + +@Composable +private fun WebViewBottomSheetContent(content: WebViewBottomSheetConfig) { + val state = rememberWebViewState(content.url) + val isInPreviewMode = LocalInspectionMode.current + Column( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + ) { + WebView( + state = state, + modifier = Modifier + .background(TangemTheme.colors.background.secondary), + captureBackPresses = false, + onCreated = { + if (!isInPreviewMode) { + it.settings.apply { + javaScriptEnabled = false + allowFileAccess = false + } + } + }, + ) + } +} + +@Preview +@Composable +private fun Preview_WebViewBottomSheetContent_InLightTheme() { + TangemTheme(isDark = false) { + WebViewBottomSheetContent( + content = WebViewBottomSheetConfig( + url = "https://tangem.com/en/", + ), + ) + } +} + +@Preview +@Composable +private fun Preview_WebViewBottomSheetContent_InDarkTheme() { + TangemTheme(isDark = true) { + WebViewBottomSheetContent( + content = WebViewBottomSheetConfig( + url = "https://tangem.com/en/", + ), + ) + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt index 7b5bd446fa..7623970edf 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt @@ -11,7 +11,6 @@ import com.tangem.feature.swap.models.ApproveType data class SwapProcessDataState( // Initial network id - val networkId: String, val fromCryptoCurrency: CryptoCurrencyStatus? = null, val toCryptoCurrency: CryptoCurrencyStatus? = null, // Amount from input diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index efdea2b2b9..c081db8e65 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -5,19 +5,22 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.* import arrow.core.getOrElse -import arrow.core.mapNotNull import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.domain.BlockchainInteractor import com.tangem.feature.swap.domain.SwapInteractor import com.tangem.feature.swap.domain.models.DataError +import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.domain.PermissionOptions import com.tangem.feature.swap.domain.models.domain.SwapDataModel import com.tangem.feature.swap.domain.models.domain.SwapProvider @@ -32,6 +35,7 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import com.tangem.utils.isNullOrZero import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -57,11 +61,12 @@ internal class SwapViewModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase, + private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver { - private val initialCryptoCurrency: CryptoCurrency = savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY] - ?: error("no expected parameter CryptoCurrency found`") + private val initialCryptoCurrency: CryptoCurrency = + savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY] ?: error("no expected parameter CryptoCurrency found`") private lateinit var initialCryptoCurrencyStatus: CryptoCurrencyStatus private var isBalanceHidden = true @@ -79,7 +84,7 @@ internal class SwapViewModel @Inject constructor( private val amountDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() - private var dataState by mutableStateOf(SwapProcessDataState(networkId = initialCryptoCurrency.network.backendId)) + private var dataState by mutableStateOf(SwapProcessDataState()) var uiState: SwapStateHolder by mutableStateOf( stateBuilder.createInitialLoadingState( @@ -93,6 +98,11 @@ internal class SwapViewModel @Inject constructor( private var isOrderReversed = false private val lastAmount = mutableStateOf(INITIAL_AMOUNT) private var swapRouter: SwapRouter by Delegates.notNull() + + private val isExchangeTooSmallAmountError: (SwapState) -> Boolean = { + it is SwapState.SwapError && it.error is DataError.ExchangeTooSmallAmountError + } + val currentScreen: SwapNavScreen get() = swapRouter.currentScreen @@ -103,26 +113,18 @@ internal class SwapViewModel @Inject constructor( requireNotNull(getCryptoCurrencyStatusUseCase(it.walletId, initialCryptoCurrency.id).getOrNull()) { "Failed to get initial crypto currency status" } - - swapInteractor.initDerivationPathAndNetwork( - derivationPath = initialCryptoCurrency.network.derivationPath.value, - network = initialCryptoCurrency.network, - ) initTokens() } } } override fun onCreate(owner: LifecycleOwner) { - getBalanceHidingSettingsUseCase() - .flowWithLifecycle(owner.lifecycle) - .onEach { - isBalanceHidden = it.isBalanceHidden - withContext(dispatchers.main) { - uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) - } + getBalanceHidingSettingsUseCase().flowWithLifecycle(owner.lifecycle).onEach { + isBalanceHidden = it.isBalanceHidden + withContext(dispatchers.main) { + uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) } - .launchIn(viewModelScope) + }.launchIn(viewModelScope) } override fun onCleared() { @@ -175,10 +177,15 @@ internal class SwapViewModel @Inject constructor( selectedCurrency = null, ) - uiState = stateBuilder.createInitialErrorState(uiState) { + uiState = stateBuilder.createInitialErrorState( + uiState, + (it as? ExpressException)?.dataError?.code ?: DataError.UnknownError.code, + ) { uiState = stateBuilder.createInitialLoadingState( initialCurrency = initialCryptoCurrency, - networkInfo = blockchainInteractor.getBlockchainInfo(initialCryptoCurrency.network.backendId), + networkInfo = blockchainInteractor.getBlockchainInfo( + initialCryptoCurrency.network.backendId, + ), ) initTokens() } @@ -209,11 +216,12 @@ internal class SwapViewModel @Inject constructor( } } - private fun updateTokensState(dataState: TokensDataStateExpress) { - val tokensDataState = if (!isOrderReversed) dataState.toGroup else dataState.fromGroup + private fun updateTokensState(tokenDataState: TokensDataStateExpress) { + val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup uiState = stateBuilder.addTokensToState( uiState = uiState, tokensDataState = tokensDataState, + fromToken = dataState.fromCryptoCurrency?.currency ?: initialCryptoCurrency, ) } @@ -276,7 +284,6 @@ internal class SwapViewModel @Inject constructor( approveDataModel = null, ) swapInteractor.findBestQuote( - networkId = dataState.networkId, fromToken = fromToken, toToken = toToken, providers = toProvidersList, @@ -289,11 +296,12 @@ internal class SwapViewModel @Inject constructor( if (providersState.isNotEmpty()) { val (provider, state) = updateLoadedQuotes(providersState) setupLoadedState(provider, state, fromToken) + val successStates = providersState.getLastLoadedSuccessStates() + val pricesLowerBest = getPricesLowerBest(successStates) uiState = stateBuilder.updateProvidersBottomSheetContent( uiState = uiState, - tokenSwapInfoForProviders = providersState - .getLastLoadedSuccessStates() - .entries + pricesLowerBest = pricesLowerBest, + tokenSwapInfoForProviders = successStates.entries .associate { it.key.providerId to it.value.toTokenInfo }, ) } else { @@ -319,8 +327,9 @@ internal class SwapViewModel @Inject constructor( fromToken = fromToken.currency, swapProvider = provider, bestRatedProviderId = bestRatedProviderId, - isManyProviders = dataState.lastLoadedSwapStates.size > 1, + isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + isReverseSwapPossible = isReverseSwapPossible(), ) if (uiState.warnings.any { it is SwapWarning.UnableToCoverFeeWarning }) { analyticsEventHandler.send( @@ -335,18 +344,19 @@ internal class SwapViewModel @Inject constructor( uiState = stateBuilder.createQuotesEmptyAmountState( uiStateHolder = uiState, emptyAmountState = state, + isReverseSwapPossible = isReverseSwapPossible(), ) } is SwapState.SwapError -> { - if (state.error is DataError.UnknownError) { - singleTaskScheduler.cancelTask() - } + singleTaskScheduler.cancelTask() uiState = stateBuilder.createQuotesErrorState( uiStateHolder = uiState, swapProvider = provider, fromToken = state.fromTokenInfo, toToken = dataState.toCryptoCurrency, dataError = state.error, + includeFeeInAmount = state.includeFeeInAmount, + isReverseSwapPossible = isReverseSwapPossible(), ) sendErrorAnalyticsEvent(state.error, provider) } @@ -381,13 +391,15 @@ internal class SwapViewModel @Inject constructor( } private fun selectProvider(state: Map): SwapProvider { - val stateSuccess = state.getLastLoadedSuccessStates() - return if (stateSuccess.isNotEmpty()) { + val consideredProviders = state.consideredProvidersStates() + + return if (consideredProviders.isNotEmpty()) { val currentSelected = dataState.selectedProvider - if (currentSelected != null && state.keys.contains(currentSelected)) { + if (currentSelected != null && consideredProviders.keys.contains(currentSelected)) { currentSelected } else { - findBestQuoteProvider(stateSuccess) ?: stateSuccess.keys.first() + findBestQuoteProvider(consideredProviders.getLastLoadedSuccessStates()) + ?: consideredProviders.keys.first() } } else { state.keys.first() @@ -407,22 +419,26 @@ internal class SwapViewModel @Inject constructor( } else { dataState.copy( swapDataModel = swapDataModel, - selectedFee = selectDefaultFee(state), + selectedFee = updateOrSelectFee(state), ) } } - private fun selectDefaultFee(state: SwapState.QuotesLoadedState): TxFee? { - return dataState.selectedFee - ?: when (val txFee = state.txFee) { - TxFeeState.Empty -> null - is TxFeeState.MultipleFeeState -> { + private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee? { + val selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL + return when (val txFee = state.txFee) { + TxFeeState.Empty -> null + is TxFeeState.MultipleFeeState -> { + if (selectedFeeType == FeeType.NORMAL) { txFee.normalFee - } - is TxFeeState.SingleFeeState -> { - txFee.fee + } else { + txFee.priorityFee } } + is TxFeeState.SingleFeeState -> { + txFee.fee + } + } } @Suppress("LongMethod") @@ -435,70 +451,68 @@ internal class SwapViewModel @Inject constructor( Timber.e("Last loaded quotes state is null") return } + val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { swapInteractor.onSwap( swapProvider = provider, - networkId = dataState.networkId, swapData = dataState.swapDataModel, - currencyToSend = requireNotNull(dataState.fromCryptoCurrency), + currencyToSend = fromCurrency, currencyToGet = requireNotNull(dataState.toCryptoCurrency), amountToSwap = requireNotNull(dataState.amount), includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = requireNotNull(dataState.selectedFee), ) - } - .onSuccess { - when (it) { - is TxState.TxSent -> { - val url = blockchainInteractor.getExplorerTransactionLink( - networkId = dataState.networkId, - txAddress = it.txAddress, - ) - uiState = stateBuilder.createSuccessState( - uiState = uiState, - timeStamp = it.timestamp, - dataState = dataState, - txUrl = url, - onExploreClick = { - val txHash = it.txAddress - if (txHash.isNotEmpty()) { - swapRouter.openUrl(url) - } + }.onSuccess { + when (it) { + is TxState.TxSent -> { + val url = blockchainInteractor.getExplorerTransactionLink( + networkId = fromCurrency.currency.network.backendId, + txHash = it.txHash, + ) + updateWalletBalance() + uiState = stateBuilder.createSuccessState( + uiState = uiState, + txState = it, + dataState = dataState, + txUrl = url, + onExploreClick = { + if (it.txHash.isNotEmpty()) { + swapRouter.openUrl(url) + } + analyticsEventHandler.send( + event = SwapEvents.ButtonExplore(initialCryptoCurrency.symbol), + ) + }, + onStatusClick = { + val txExternalUrl = it.txExternalUrl + if (!txExternalUrl.isNullOrBlank()) { + swapRouter.openUrl(txExternalUrl) analyticsEventHandler.send( - event = SwapEvents.ButtonExplore(initialCryptoCurrency.symbol), + event = SwapEvents.ButtonStatus(initialCryptoCurrency.symbol), ) - }, - onStatusClick = { - val txExternalUrl = it.txExternalUrl - if (!txExternalUrl.isNullOrBlank()) { - swapRouter.openUrl(txExternalUrl) - analyticsEventHandler.send( - event = SwapEvents.ButtonStatus(initialCryptoCurrency.symbol), - ) - } - }, - ) - sendSuccessEvent() + } + }, + ) + sendSuccessEvent() - swapRouter.openScreen(SwapNavScreen.Success) - } - is TxState.UserCancelled -> { - startLoadingQuotesFromLastState() - } - else -> { - startLoadingQuotesFromLastState() - uiState = stateBuilder.createErrorTransaction(uiState, it) { - uiState = stateBuilder.clearAlert(uiState) - } + swapRouter.openScreen(SwapNavScreen.Success) + } + is TxState.UserCancelled -> { + startLoadingQuotesFromLastState() + } + else -> { + startLoadingQuotesFromLastState() + uiState = stateBuilder.createErrorTransaction(uiState, it) { + uiState = stateBuilder.clearAlert(uiState) } } } - .onFailure { - Timber.e(it) - startLoadingQuotesFromLastState() - makeDefaultAlert() - } + }.onFailure { + Timber.e(it) + startLoadingQuotesFromLastState() + makeDefaultAlert() + } } } @@ -527,18 +541,18 @@ internal class SwapViewModel @Inject constructor( is TxFeeState.SingleFeeState -> fee.fee null -> error("Fee should not be null") } + val fromToken = requireNotNull(dataState.fromCryptoCurrency?.currency) { + "dataState.fromCurrency might not be null" + } swapInteractor.givePermissionToSwap( - networkId = dataState.networkId, + networkId = fromToken.network.backendId, permissionOptions = PermissionOptions( approveData = requireNotNull(dataState.approveDataModel) { "dataState.approveDataModel might not be null" }, forTokenContractAddress = (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Token) - ?.contractAddress - ?: "", - fromToken = requireNotNull(dataState.fromCryptoCurrency?.currency) { - "dataState.fromCurrency might not be null" - }, + ?.contractAddress ?: "", + fromToken = fromToken, approveType = requireNotNull(dataState.approveType) { "uiState.permissionState should not be null" }.toDomainApproveType(), @@ -548,24 +562,23 @@ internal class SwapViewModel @Inject constructor( }, ), ) - } - .onSuccess { - when (it) { - is TxState.TxSent -> { - uiState = stateBuilder.loadingPermissionState(uiState) - uiState = stateBuilder.dismissBottomSheet(uiState) - } - is TxState.UserCancelled -> Unit - else -> { - uiState = stateBuilder.createErrorTransaction(uiState, it) { - uiState = stateBuilder.clearAlert(uiState) - } + }.onSuccess { + when (it) { + is TxState.TxSent -> { + uiState = stateBuilder.loadingPermissionState(uiState) + uiState = stateBuilder.dismissBottomSheet(uiState) + startLoadingQuotesFromLastState(isSilent = true) + } + is TxState.UserCancelled -> Unit + else -> { + uiState = stateBuilder.createErrorTransaction(uiState, it) { + uiState = stateBuilder.clearAlert(uiState) } } } - .onFailure { - makeDefaultAlert() - } + }.onFailure { + makeDefaultAlert() + } } } @@ -578,23 +591,28 @@ internal class SwapViewModel @Inject constructor( tokenDataState.toGroup } val available = group.available.filter { - it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) + it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || + it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) } val unavailable = group.unavailable.filter { - it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) + it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || + it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) } val filteredTokenDataState = if (isOrderReversed) { tokenDataState.copy( fromGroup = tokenDataState.fromGroup.copy( available = available, unavailable = unavailable, + afterSearch = true, ), + ) } else { tokenDataState.copy( toGroup = tokenDataState.toGroup.copy( available = available, unavailable = unavailable, + afterSearch = true, ), ) } @@ -646,12 +664,17 @@ internal class SwapViewModel @Inject constructor( private fun onChangeCardsClicked() { val newFromToken = dataState.toCryptoCurrency val newToToken = dataState.fromCryptoCurrency + if (newFromToken != null && newToToken != null) { dataState = dataState.copy( fromCryptoCurrency = newFromToken, toCryptoCurrency = newToToken, ) isOrderReversed = !isOrderReversed + dataState.tokensDataState?.let { + updateTokensState(it) + } + val decimals = newFromToken.currency.decimals lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value) uiState = stateBuilder.updateSwapAmount( @@ -670,19 +693,22 @@ internal class SwapViewModel @Inject constructor( private fun onAmountChanged(value: String) { val fromToken = dataState.fromCryptoCurrency val toToken = dataState.toCryptoCurrency - if (fromToken != null && toToken != null) { + if (fromToken != null) { val decimals = fromToken.currency.decimals val cutValue = cutAmountWithDecimals(decimals, value) lastAmount.value = cutValue uiState = stateBuilder.updateSwapAmount(uiState, inputNumberFormatter.formatWithThousands(cutValue, decimals)) - amountDebouncer.debounce(viewModelScope, DEBOUNCE_AMOUNT_DELAY) { - startLoadingQuotes( - fromToken = fromToken, - toToken = toToken, - amount = lastAmount.value, - toProvidersList = findSwapProviders(fromToken, toToken), - ) + + if (toToken != null) { + amountDebouncer.debounce(viewModelScope, DEBOUNCE_AMOUNT_DELAY) { + startLoadingQuotes( + fromToken = fromToken, + toToken = toToken, + amount = lastAmount.value, + toProvidersList = findSwapProviders(fromToken, toToken), + ) + } } } } @@ -732,16 +758,7 @@ internal class SwapViewModel @Inject constructor( }, onGivePermissionClick = { givePermissionsToSwap() - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol - if (sendTokenSymbol != null && receiveTokenSymbol != null) { - analyticsEventHandler.send( - SwapEvents.ButtonPermissionApproveClicked( - sendToken = sendTokenSymbol, - receiveToken = receiveTokenSymbol, - ), - ) - } + sendPermissionApproveClickedEvent() }, onChangeCardsClicked = { onChangeCardsClicked() @@ -776,8 +793,8 @@ internal class SwapViewModel @Inject constructor( }, onClickFee = { val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL - val txFeeState = dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState - ?: return@UiActions + val txFeeState = + dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions uiState = stateBuilder.showSelectFeeBottomSheet( uiState = uiState, selectedFee = selectedFee, @@ -791,7 +808,7 @@ internal class SwapViewModel @Inject constructor( val fromToken = dataState.fromCryptoCurrency ?: return@UiActions val amountToSwap = dataState.amount ?: return@UiActions val selectedProvider = dataState.selectedProvider ?: return@UiActions - uiState = stateBuilder.updateSelectedFeeBottomSheet(uiState, it.feeType) + uiState = stateBuilder.dismissBottomSheet(uiState) dataState = dataState.copy(selectedFee = it) viewModelScope.launch(dispatchers.io) { val updatedState = swapInteractor.updateQuotesStateWithSelectedFee( @@ -799,7 +816,6 @@ internal class SwapViewModel @Inject constructor( selectedFee = it.feeType, fromToken = fromToken, amountToSwap = amountToSwap, - networkId = dataState.networkId, ) setupLoadedState(selectedProvider, updatedState, fromToken) } @@ -823,7 +839,7 @@ internal class SwapViewModel @Inject constructor( val fromToken = dataState.fromCryptoCurrency if (provider != null && swapState != null && fromToken != null) { analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) - uiState = stateBuilder.updateSelectedProvider(uiState, provider.providerId) + uiState = stateBuilder.dismissBottomSheet(uiState) setupLoadedState( provider = provider, state = swapState, @@ -833,12 +849,22 @@ internal class SwapViewModel @Inject constructor( }, onBuyClick = { swapInteractor.getSelectedWallet()?.let { - swapRouter.openTokenDetails(it.walletId, swapInteractor.getNativeToken(dataState.networkId)) + val fromToken = dataState.fromCryptoCurrency ?: return@let + swapRouter.openTokenDetails( + it.walletId, + swapInteractor.getNativeToken(fromToken.currency.network.backendId), + ) } }, onRetryClick = { startLoadingQuotesFromLastState() }, + onPolicyClick = { + swapRouter.openUrl(it) + }, + onTosClick = { + swapRouter.openUrl(it) + }, ) } @@ -855,9 +881,7 @@ internal class SwapViewModel @Inject constructor( private fun findBestQuoteProvider(state: SuccessLoadedSwapData): SwapProvider? { // finding best quotes return state.minByOrNull { - if (!it.value.fromTokenInfo.amountFiat.isNullOrZero() && - !it.value.toTokenInfo.amountFiat.isNullOrZero() - ) { + if (!it.value.fromTokenInfo.amountFiat.isNullOrZero() && !it.value.toTokenInfo.amountFiat.isNullOrZero()) { it.value.fromTokenInfo.amountFiat.divide( it.value.toTokenInfo.amountFiat, it.value.toTokenInfo.cryptoCurrencyStatus.currency.decimals, @@ -869,33 +893,31 @@ internal class SwapViewModel @Inject constructor( }?.key } - private fun getPricesLowerBest(state: SuccessLoadedSwapData): Map { + private fun getPricesLowerBest(state: SuccessLoadedSwapData): Map { val bestRateEntry = state.maxByOrNull { it.value.toTokenInfo.tokenAmount.value } ?: return emptyMap() val bestRate = bestRateEntry.value.toTokenInfo.tokenAmount.value val hundredPercent = BigDecimal("100") - return state.mapNotNull { + return state.entries.mapNotNull { if (it.key != bestRateEntry.key) { val amount = it.value.toTokenInfo.tokenAmount.value val percentDiff = BigDecimal.ONE.minus( amount.divide(bestRate, RoundingMode.HALF_UP), ).multiply(hundredPercent) - percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat().absoluteValue + it.key.providerId to percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat().absoluteValue } else { null } - } + }.toMap() } private fun createSelectedAppCurrencyFlow(): StateFlow { - return getSelectedAppCurrencyUseCase() - .map { maybeAppCurrency -> - maybeAppCurrency.getOrElse { AppCurrency.Default } - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = AppCurrency.Default, - ) + return getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) } private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List { @@ -915,27 +937,83 @@ internal class SwapViewModel @Inject constructor( } private fun getAllProviders(): List { - dataState.tokensDataState?.let { data -> - val allProviders = - data.fromGroup.available.flatMap { it.providers } + data.toGroup.available.flatMap { it.providers } - return allProviders.distinct() - } ?: return emptyList() + return dataState.tokensDataState?.allProviders ?: emptyList() } private fun getUnavailableProvidersFor(state: Map): List { - return getAllProviders().filterNot { it in state } + val availableProviders = state.keys.map { it.providerId } + return getAllProviders().filterNot { availableProviders.contains(it.providerId) } } private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { - return this - .filter { it.value is SwapState.QuotesLoadedState } + return this.filter { it.value is SwapState.QuotesLoadedState } .mapValues { it.value as SwapState.QuotesLoadedState } } + private fun Map.consideredProvidersStates(): Map { + return this.filter { + it.value is SwapState.QuotesLoadedState || isExchangeTooSmallAmountError(it.value) + } + } + + private fun isReverseSwapPossible(): Boolean { + val from = dataState.fromCryptoCurrency ?: return false + val to = dataState.toCryptoCurrency ?: return false + + val currenciesGroup = if (isOrderReversed) { + dataState.tokensDataState?.toGroup + } else { + dataState.tokensDataState?.fromGroup + } ?: return false + + val chosen = if (isOrderReversed) from else to + + return currenciesGroup.available + .map { it.currencyStatus.currency } + .contains(chosen.currency) + } + + private fun sendPermissionApproveClickedEvent() { + val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol + val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol + val approveType = dataState.approveType + if (sendTokenSymbol != null && receiveTokenSymbol != null && approveType != null) { + analyticsEventHandler.send( + SwapEvents.ButtonPermissionApproveClicked( + sendToken = sendTokenSymbol, + receiveToken = receiveTokenSymbol, + approveType = approveType, + ), + ) + } + } + + private fun updateWalletBalance() { + swapInteractor.getSelectedWallet()?.let { userWallet -> + dataState.fromCryptoCurrency?.currency?.network?.let { network -> + viewModelScope.launch { + withContext(NonCancellable) { + updateForBalance(userWallet, network) + } + } + } + } + } + + private suspend fun updateForBalance(userWallet: UserWallet, network: Network) { + updateDelayedCurrencyStatusUseCase( + userWalletId = userWallet.walletId, + network = network, + delayMillis = UPDATE_BALANCE_DELAY_MILLIS, + refresh = true, + ) + } + companion object { private const val loggingTag = "SwapViewModel" private const val INITIAL_AMOUNT = "" private const val UPDATE_DELAY = 10000L private const val DEBOUNCE_AMOUNT_DELAY = 1000L + private const val UPDATE_BALANCE_DELAY_MILLIS = 11000L } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/res/drawable/ic_lightning_16.xml b/features/swap/presentation/src/main/res/drawable/ic_lightning_16.xml new file mode 100644 index 0000000000..f796778e0e --- /dev/null +++ b/features/swap/presentation/src/main/res/drawable/ic_lightning_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 8c4c106fa0..860b64eec4 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -70,9 +70,10 @@ dependencies { /** Temp dependency to swap domain */ implementation(projects.features.swap.domain) + implementation(projects.features.swap.domain.api) + implementation(projects.features.swap.domain.models) /** Feature Apis */ implementation(projects.features.tokendetails.api) implementation(projects.features.send.api) - implementation(projects.features.swap.domain) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/SwapTransactionsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/SwapTransactionsState.kt index 5e42550ab6..c1e0f67210 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/SwapTransactionsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/SwapTransactionsState.kt @@ -1,12 +1,13 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state +import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.currency.tokenicon.TokenIconState import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.collections.immutable.ImmutableList internal data class SwapTransactionsState( val txId: String, @@ -14,10 +15,10 @@ internal data class SwapTransactionsState( val txUrl: String? = null, val timestamp: TextReference, val fiatSymbol: String, - val activeStatus: MutableStateFlow, - val hasFailed: MutableStateFlow, - val statuses: MutableStateFlow>, - val notification: MutableStateFlow = MutableStateFlow(null), + val activeStatus: ExchangeStatus?, + val hasFailed: Boolean, + val statuses: ImmutableList, + val notification: ExchangeStatusNotifications? = null, val toCryptoCurrencyId: CryptoCurrency.ID, val toCryptoAmount: String, val toCryptoSymbol: String, @@ -28,10 +29,12 @@ internal data class SwapTransactionsState( val fromCryptoSymbol: String, val fromFiatAmount: String, val fromCurrencyIcon: TokenIconState, + val showProviderLink: Boolean, val onClick: () -> Unit, val onGoToProviderClick: (String) -> Unit, ) +@Immutable internal class ExchangeStatusState( val status: ExchangeStatus, val text: TextReference, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt index 8af1d13a13..63130c7889 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsActionButton.kt @@ -78,7 +78,7 @@ internal sealed class TokenDetailsActionButton(val config: ActionButtonConfig) { */ data class Swap(val enabled: Boolean, override val onClick: () -> Unit) : TokenDetailsActionButton( config = ActionButtonConfig( - text = TextReference.Res(id = R.string.common_swap), + text = TextReference.Res(id = R.string.swapping_swap_action), iconResId = R.drawable.ic_exchange_vertical_24, onClick = onClick, enabled = enabled, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt index 989aa84d9b..dbe63a69f8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt @@ -39,6 +39,23 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) { ), ) + data class SwapPromo( + val onSwapClick: () -> Unit, + val onCloseClick: () -> Unit, + ) : TokenDetailsNotification( + config = NotificationConfig( + title = resourceReference(id = R.string.token_swap_promotion_title), + subtitle = resourceReference(id = R.string.token_swap_promotion_message), + iconResId = R.drawable.img_swap_promo, + backgroundResId = R.drawable.img_swap_promo_banner_background, + onCloseClick = onCloseClick, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(id = com.tangem.core.ui.R.string.token_swap_promotion_button), + onClick = onSwapClick, + ), + ), + ) + object NetworksUnreachable : Warning( title = resourceReference(R.string.warning_network_unreachable_title), subtitle = resourceReference(R.string.warning_network_unreachable_message), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index d9362571ca..de2e921017 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt @@ -45,6 +45,10 @@ internal class TokenDetailsNotificationConverter( is CryptoCurrencyWarning.HasPendingTransactions -> TokenDetailsNotification.HasPendingTransactions( coinSymbol = warning.blockchainSymbol, ) + is CryptoCurrencyWarning.SwapPromo -> TokenDetailsNotification.SwapPromo( + onSwapClick = clickIntents::onSwapPromoClick, + onCloseClick = clickIntents::onSwapPromoDismiss, + ) } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index ca721fcb07..b4b567a444 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -34,6 +34,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +@Suppress("TooManyFunctions") internal class TokenDetailsStateFactory( private val currentStateProvider: Provider, private val appCurrencyProvider: Provider, @@ -247,6 +248,19 @@ internal class TokenDetailsStateFactory( ) } + fun updateStateWithExchangeStatusBottomSheet(swapTxState: SwapTransactionsState): TangemBottomSheetConfig? { + val state = currentStateProvider() + val bottomSheetConfig = state.bottomSheetConfig + val currentConfig = bottomSheetConfig?.content as? ExchangeStatusBottomSheetConfig ?: return bottomSheetConfig + return bottomSheetConfig.copy( + content = if (currentConfig.value != swapTxState) { + ExchangeStatusBottomSheetConfig(swapTxState) + } else { + currentConfig + }, + ) + } + fun getStateAndTriggerEvent( state: TokenDetailsState, errorMessage: TextReference, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index 27d393217c..aace511e3a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -20,11 +20,10 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.Toke import com.tangem.features.tokendetails.impl.R import com.tangem.utils.Provider import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update import java.math.BigDecimal internal class TokenDetailsSwapTransactionsStateConverter( @@ -63,21 +62,21 @@ internal class TokenDetailsSwapTransactionsStateConverter( val toFiatAmount = toAmount.multiply(toCurrency.value.fiatRate) val fromFiatAmount = fromAmount.multiply(fromCurrency.value.fiatRate) val timestamp = transaction.timestamp + val notifications = getNotification(transaction.status?.status, transaction.status?.txExternalUrl) + val showProviderLink = getShowProviderLink(notifications, transaction.status) result.add( SwapTransactionsState( txId = transaction.txId, provider = transaction.provider, - txUrl = transaction.status?.txUrl, + txUrl = transaction.status?.txExternalUrl, timestamp = TextReference.Str("${timestamp.toDateFormat()}, ${timestamp.toTimeFormat()}"), fiatSymbol = appCurrency.symbol, - statuses = MutableStateFlow(getStatuses(transaction.status?.status)), - hasFailed = MutableStateFlow(transaction.status?.status == ExchangeStatus.Failed), - activeStatus = MutableStateFlow(transaction.status?.status), - notification = MutableStateFlow( - getNotification( - transaction.status?.status, - transaction.status?.txUrl, - ), + statuses = getStatuses(transaction.status?.status), + hasFailed = transaction.status?.status == ExchangeStatus.Failed, + activeStatus = transaction.status?.status, + notification = getNotification( + transaction.status?.status, + transaction.status?.txExternalUrl, ), toCryptoCurrencyId = toCurrency.currency.id, toCryptoAmount = BigDecimalFormatter.formatCryptoAmount( @@ -95,6 +94,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( fromCryptoSymbol = fromCurrency.currency.symbol, fromFiatAmount = getFiatAmount(fromFiatAmount), fromCurrencyIcon = iconStateConverter.convert(fromCurrency), + showProviderLink = showProviderLink, onClick = { clickIntents.onSwapTransactionClick(transaction.txId) }, onGoToProviderClick = { url -> analyticsEventsHandlerProvider().send( @@ -109,13 +109,19 @@ internal class TokenDetailsSwapTransactionsStateConverter( return result.toPersistentList() } - fun updateTxStatus(tx: SwapTransactionsState, statusModel: ExchangeStatusModel?) { - if (statusModel == null || tx.activeStatus.value == statusModel.status) return - val hasFailed = tx.hasFailed.value || statusModel.status == ExchangeStatus.Failed - tx.activeStatus.update { statusModel.status } - tx.hasFailed.update { hasFailed } - tx.notification.update { getNotification(statusModel.status, statusModel.txUrl) } - tx.statuses.update { getStatuses(statusModel.status, hasFailed) } + fun updateTxStatus(tx: SwapTransactionsState, statusModel: ExchangeStatusModel?): SwapTransactionsState { + if (statusModel == null || tx.activeStatus == statusModel.status) return tx + val hasFailed = tx.hasFailed || statusModel.status == ExchangeStatus.Failed + val notifications = getNotification(statusModel.status, statusModel.txExternalUrl) + val showProviderLink = getShowProviderLink(notifications, statusModel) + return tx.copy( + activeStatus = statusModel.status, + hasFailed = hasFailed, + notification = notifications, + statuses = getStatuses(statusModel.status, hasFailed), + txUrl = statusModel.txExternalUrl, + showProviderLink = showProviderLink, + ) } private fun getFiatAmount(toFiatAmount: BigDecimal): String { @@ -149,8 +155,11 @@ internal class TokenDetailsSwapTransactionsStateConverter( } } - private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): List { - if (status == null) return emptyList() + private fun getShowProviderLink(notifications: ExchangeStatusNotifications?, status: ExchangeStatusModel?) = + notifications == null && status?.txExternalUrl != null && status.status != ExchangeStatus.Cancelled + + private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): ImmutableList { + if (status == null) return persistentListOf() val isWaiting = status == ExchangeStatus.New || status == ExchangeStatus.Waiting val isConfirming = status == ExchangeStatus.Confirming val isVerifying = status == ExchangeStatus.Verifying @@ -164,26 +173,37 @@ internal class TokenDetailsSwapTransactionsStateConverter( val isExchangingDone = !isExchanging && isConfirmingDone val isSendingDone = !isSending && !isVerifying && !isFailed && isExchangingDone - return listOf( - waitStep(isWaiting, isWaitingDone), - confirmStep(isConfirming, isConfirmingDone), - exchangeStep( - isExchanging = isExchanging, - isExchangingDone = isExchangingDone, - isRefunded = isRefunded, - hasFailed = hasFailed, - isVerifying = isVerifying, - isFailed = isFailed, - ), - sendStep( - isSending = isSending, - isSendingDone = isSendingDone, - isRefunded = isRefunded, - hasFailed = hasFailed, - ), - ) + return if (status == ExchangeStatus.Cancelled) { + listOf(cancelledStep()) + } else { + listOf( + waitStep(isWaiting, isWaitingDone), + confirmStep(isConfirming, isConfirmingDone), + exchangeStep( + isExchanging = isExchanging, + isExchangingDone = isExchangingDone, + isRefunded = isRefunded, + hasFailed = hasFailed, + isVerifying = isVerifying, + isFailed = isFailed, + ), + sendStep( + isSending = isSending, + isSendingDone = isSendingDone, + isRefunded = isRefunded, + hasFailed = hasFailed, + ), + ) + }.toPersistentList() } + private fun cancelledStep() = ExchangeStatusState( + status = ExchangeStatus.Cancelled, + text = TextReference.Res(R.string.express_exchange_status_canceled), + isActive = true, + isDone = true, + ) + private fun waitStep(isNew: Boolean, isNewDone: Boolean) = ExchangeStatusState( status = ExchangeStatus.New, text = when { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 57f5e40688..1f1ae20a89 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -28,6 +28,7 @@ import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBotto import com.tangem.core.ui.components.marketprice.MarketPriceBlock import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationWithBackground import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.components.transactions.txHistoryItems import com.tangem.core.ui.event.EventEffect @@ -104,14 +105,21 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) { key = { it::class.java }, contentType = { it.config::class.java }, itemContent = { - Notification( - modifier = itemModifier.animateItemPlacement(), - config = it.config, - iconTint = when (it) { - is TokenDetailsNotification.Warning -> null - is TokenDetailsNotification.Informational -> TangemTheme.colors.icon.accent - }, - ) + if (it is TokenDetailsNotification.SwapPromo) { + NotificationWithBackground( + config = it.config, + modifier = itemModifier.animateItemPlacement(), + ) + } else { + Notification( + modifier = itemModifier.animateItemPlacement(), + config = it.config, + iconTint = when (it) { + is TokenDetailsNotification.Informational -> TangemTheme.colors.icon.accent + else -> null + }, + ) + } }, ) if (state.isMarketPriceAvailable) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBlock.kt index f5196025aa..c715a6c6bd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBlock.kt @@ -18,23 +18,21 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.tokendetails.presentation.tokendetails.state.ExchangeStatusState import com.tangem.features.tokendetails.impl.R -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.collections.immutable.ImmutableList @Composable internal fun ExchangeStatusBlock( - statuses: MutableStateFlow>, + statuses: ImmutableList, showLink: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val statusValues = statuses.collectAsStateWithLifecycle() Column( modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) @@ -77,11 +75,15 @@ internal fun ExchangeStatusBlock( } } - statusValues.value.forEachIndexed { index, item -> - ExchangeStatusStep( - stepStatus = item, - isLast = index == statusValues.value.lastIndex, - ) + AnimatedContent(targetState = statuses.lastIndex, label = "Exchange Status List Change") { + Column { + statuses.forEachIndexed { index, item -> + ExchangeStatusStep( + stepStatus = item, + isLast = index == it, + ) + } + } } } } @@ -103,6 +105,11 @@ private fun ExchangeStatusStep( .size(TangemTheme.dimens.size20), ) { when { + it.status == ExchangeStatus.Cancelled -> ExchangeStep( + iconRes = R.drawable.ic_close_24, + color = TangemTheme.colors.icon.warning, + isDone = false, + ) it.status == ExchangeStatus.Failed -> ExchangeStep( iconRes = R.drawable.ic_close_24, color = TangemTheme.colors.icon.warning, @@ -133,6 +140,7 @@ private fun ExchangeStatusStep( @Composable private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) { val textColor = when { + stepStatus.status == ExchangeStatus.Cancelled -> TangemTheme.colors.icon.warning stepStatus.status == ExchangeStatus.Failed && !stepStatus.isDone -> TangemTheme.colors.icon.warning stepStatus.status == ExchangeStatus.Verifying && !stepStatus.isDone -> TangemTheme.colors.icon.attention stepStatus.isDone -> TangemTheme.colors.text.primary1 diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBottomSheet.kt index 315d86a347..662098fbd7 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBottomSheet.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBottomSheet.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.text.style.TextAlign import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH10 import com.tangem.core.ui.components.SpacerH12 @@ -36,8 +36,6 @@ internal fun ExchangeStatusBottomSheet(config: TangemBottomSheetConfig) { @Composable private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetConfig) { val config = content.value - val status = config.activeStatus.collectAsStateWithLifecycle() - val notification = config.notification.collectAsStateWithLifecycle() Column( modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), ) { @@ -53,6 +51,7 @@ private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetC text = stringResource(id = R.string.express_exchange_status_subtitle), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, modifier = Modifier .align(CenterHorizontally), ) @@ -77,15 +76,15 @@ private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetC SpacerH12() ExchangeStatusBlock( statuses = config.statuses, - showLink = notification.value == null && config.txUrl != null, + showLink = config.showProviderLink, onClick = { config.onGoToProviderClick(config.txUrl.orEmpty()) }, ) AnimatedContent( - targetState = notification.value, + targetState = config.notification, label = "Exchange Status Notification Change", ) { it?.let { - val tint = when (status.value) { + val tint = when (config.activeStatus) { ExchangeStatus.Verifying -> TangemTheme.colors.icon.attention ExchangeStatus.Failed -> TangemTheme.colors.icon.warning else -> null diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusItems.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusItems.kt index c73e3e3051..94c1d12187 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusItems.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusItems.kt @@ -4,7 +4,9 @@ import androidx.annotation.DrawableRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -18,7 +20,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.constraintlayout.compose.* -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.currency.tokenicon.TokenIcon @@ -41,10 +42,11 @@ internal fun LazyListScope.swapTransactionsItems( contentType = { swapTxs[it]::class.java }, ) { val item = swapTxs[it] - val status = item.activeStatus.collectAsStateWithLifecycle() - val (iconRes, tint) = when (status.value) { + val (iconRes, tint) = when (item.activeStatus) { ExchangeStatus.Verifying -> R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention - ExchangeStatus.Failed -> R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning + ExchangeStatus.Failed, ExchangeStatus.Cancelled -> { + R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning + } else -> null to null } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt index 441b401e24..a2e8461c92 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt @@ -11,14 +11,16 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import com.tangem.feature.swap.domain.SwapRepository import com.tangem.feature.swap.domain.SwapTransactionRepository +import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter import com.tangem.utils.Provider +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf @@ -41,6 +43,7 @@ internal class ExchangeStatusFactory( private val clickIntents: TokenDetailsClickIntents, private val appCurrencyProvider: Provider, private val analyticsEventsHandlerProvider: Provider, + private val currentStateProvider: Provider, private val userWalletId: UserWalletId, private val cryptoCurrency: CryptoCurrency, ) { @@ -54,7 +57,7 @@ internal class ExchangeStatusFactory( ) } - operator fun invoke() = combine( + suspend operator fun invoke() = combine( flow = swapTransactionRepository.getTransactions(userWalletId, cryptoCurrency.id), flow2 = getWalletCryptoCurrencies().conflate(), ) { savedTransactions, cryptoCurrenciesStatusList -> @@ -64,6 +67,27 @@ internal class ExchangeStatusFactory( ) } + suspend fun removeTransactionOnBottomSheetClosed(): TokenDetailsState { + val state = currentStateProvider() + val bottomSheetConfig = state.bottomSheetConfig?.content as? ExchangeStatusBottomSheetConfig ?: return state + val selectedTx = bottomSheetConfig.value + + return if (selectedTx.activeStatus.isTerminal()) { + swapTransactionRepository.removeTransaction( + userWalletId = userWalletId, + fromCryptoCurrencyId = selectedTx.fromCryptoCurrencyId, + toCryptoCurrencyId = selectedTx.toCryptoCurrencyId, + txId = selectedTx.txId, + ) + val filteredTxs = state.swapTxs + .filterNot { it.txId == selectedTx.txId } + .toPersistentList() + state.copy(swapTxs = filteredTxs) + } else { + state + } + } + private fun getWalletCryptoCurrencies() = flow { val selectedWallet = getSelectedWalletSyncUseCase().fold( ifLeft = { null }, @@ -80,13 +104,16 @@ internal class ExchangeStatusFactory( suspend fun updateSwapTxStatuses(swapTxList: PersistentList) = withContext(dispatchers.io) { swapTxList.map { tx -> async { - val statusModel = getExchangeStatus(tx.txId) - swapTransactionsStateConverter.updateTxStatus(tx, statusModel) - tx.removeIfFinished(statusModel?.status) + if (tx.activeStatus.isTerminal()) { + tx + } else { + val statusModel = getExchangeStatus(tx.txId) + swapTransactionsStateConverter + .updateTxStatus(tx, statusModel) + } } } .awaitAll() - .filterNotNull() .toPersistentList() } @@ -94,9 +121,10 @@ internal class ExchangeStatusFactory( return swapRepository.getExchangeStatus(txId) .fold( ifLeft = { null }, - ifRight = { - sendStatusUpdateAnalytics(it) - it + ifRight = { statusModel -> + sendStatusUpdateAnalytics(statusModel) + swapTransactionRepository.storeTransactionState(txId, statusModel) + statusModel }, ) } @@ -128,21 +156,8 @@ internal class ExchangeStatusFactory( ) } - private suspend fun SwapTransactionsState.removeIfFinished(status: ExchangeStatus?) = when (status) { - null -> null // not found - ExchangeStatus.Refunded, ExchangeStatus.Finished -> { - swapTransactionRepository.removeTransaction( - userWalletId = userWalletId, - fromCryptoCurrencyId = fromCryptoCurrencyId, - toCryptoCurrencyId = toCryptoCurrencyId, - txId = txId, - ) - null - } - else -> { - this - } - } + private fun ExchangeStatus?.isTerminal() = + this == ExchangeStatus.Refunded || this == ExchangeStatus.Finished || this == ExchangeStatus.Cancelled private fun toAnalyticStatus(status: ExchangeStatus?): ExchangeAnalyticsStatus? { return when (status) { @@ -156,6 +171,7 @@ internal class ExchangeStatusFactory( ExchangeStatus.Failed -> ExchangeAnalyticsStatus.Fail ExchangeStatus.Finished -> ExchangeAnalyticsStatus.Done ExchangeStatus.Refunded -> ExchangeAnalyticsStatus.Refunded + ExchangeStatus.Cancelled -> ExchangeAnalyticsStatus.Cancelled else -> null } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt index 95093f7fdb..d049e3bf31 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel import com.tangem.domain.tokens.model.CryptoCurrency +@Suppress("TooManyFunctions") interface TokenDetailsClickIntents { fun onBackClick() @@ -42,4 +43,8 @@ interface TokenDetailsClickIntents { fun onSwapTransactionClick(txId: String) fun onGoToProviderClick(url: String) + + fun onSwapPromoDismiss() + + fun onSwapPromoClick() } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index 6f6a87c93f..5e683506fa 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt @@ -19,6 +19,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.settings.ShouldShowSwapPromoTokenUseCase import com.tangem.domain.tokens.* import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.CryptoCurrency @@ -27,6 +28,7 @@ import com.tangem.domain.tokens.model.NetworkAddress import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.tokens.models.analytics.TokenReceiveAnalyticsEvent import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent +import com.tangem.domain.tokens.models.analytics.TokenSwapPromoAnalyticsEvent import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase @@ -34,12 +36,13 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.feature.swap.domain.SwapRepository import com.tangem.feature.swap.domain.SwapTransactionRepository +import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig import com.tangem.features.tokendetails.impl.R import com.tangem.features.tokendetails.navigation.TokenDetailsRouter import com.tangem.utils.Provider @@ -74,6 +77,7 @@ internal class TokenDetailsViewModel @Inject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + private val shouldShowSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase, private val swapRepository: SwapRepository, private val swapTransactionRepository: SwapTransactionRepository, private val swapTransactionStatusStore: SwapTransactionStatusStore, @@ -121,6 +125,7 @@ internal class TokenDetailsViewModel @Inject constructor( clickIntents = this, appCurrencyProvider = Provider { selectedAppCurrencyFlow.value }, analyticsEventsHandlerProvider = Provider { analyticsEventsHandler }, + currentStateProvider = Provider { uiState }, userWalletId = userWalletId, cryptoCurrency = cryptoCurrency, ) @@ -215,20 +220,21 @@ internal class TokenDetailsViewModel @Inject constructor( viewModelScope.launch(dispatchers.io) { swapTxStatusTaskScheduler.cancelTask() exchangeStatusFactory.invoke() + .distinctUntilChanged() + .filterNot { it.isEmpty() } .onEach { swapTxs -> + updateSwapTx(swapTxs) swapTxStatusTaskScheduler.scheduleTask( viewModelScope, PeriodicTask( delay = EXCHANGE_STATUS_UPDATE_DELAY, task = { runCatching(dispatchers.io) { - exchangeStatusFactory.updateSwapTxStatuses(swapTxs) + exchangeStatusFactory.updateSwapTxStatuses(uiState.swapTxs) } }, - onSuccess = { updatedTxs -> - uiState = uiState.copy(swapTxs = updatedTxs) - }, - onError = {}, + onSuccess = ::updateSwapTx, + onError = { /* no-op */ }, ), ) } @@ -238,6 +244,18 @@ internal class TokenDetailsViewModel @Inject constructor( } } + private fun updateSwapTx(swapTxs: PersistentList) { + val config = uiState.bottomSheetConfig + val exchangeBottomSheet = config?.content as? ExchangeStatusBottomSheetConfig + val currentTx = swapTxs.firstOrNull { it.txId == exchangeBottomSheet?.value?.txId } + uiState = uiState.copy( + swapTxs = swapTxs, + bottomSheetConfig = currentTx?.let( + stateFactory::updateStateWithExchangeStatusBottomSheet, + ) ?: config, + ) + } + /** * @param refresh - invalidate cache and get data from remote * @param showItemsLoading - show loading items placeholder. @@ -319,7 +337,7 @@ internal class TokenDetailsViewModel @Inject constructor( val cryptoCurrencyStatus = cryptoCurrencyStatus ?: return - viewModelScope.launch(dispatchers.io) { + viewModelScope.launch(dispatchers.main) { when (val currency = cryptoCurrencyStatus.currency) { is CryptoCurrency.Coin -> { reduxStateHolder.dispatch( @@ -349,7 +367,7 @@ internal class TokenDetailsViewModel @Inject constructor( .distinctUntilChanged() .firstOrNull() - reduxStateHolder.dispatch( + reduxStateHolder.dispatchWithMain( action = TradeCryptoAction.New.SendToken( userWallet = wallet, tokenCurrency = tokenCurrency, @@ -530,6 +548,11 @@ internal class TokenDetailsViewModel @Inject constructor( } override fun onDismissBottomSheet() { + if (uiState.bottomSheetConfig?.content is ExchangeStatusBottomSheetConfig) { + viewModelScope.launch(dispatchers.main) { + uiState = exchangeStatusFactory.removeTransactionOnBottomSheetClosed() + } + } uiState = stateFactory.getStateWithClosedBottomSheet() } @@ -547,6 +570,21 @@ internal class TokenDetailsViewModel @Inject constructor( router.openUrl(url) } + override fun onSwapPromoDismiss() { + viewModelScope.launch(dispatchers.main) { + shouldShowSwapPromoTokenUseCase.neverToShow() + analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Close) + } + } + + override fun onSwapPromoClick() { + viewModelScope.launch(dispatchers.main) { + shouldShowSwapPromoTokenUseCase.neverToShow() + analyticsEventsHandler.send(TokenSwapPromoAnalyticsEvent.Exchange(cryptoCurrency.symbol)) + } + onSwapClick() + } + private companion object { const val EXCHANGE_STATUS_UPDATE_DELAY = 10_000L } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkGroupItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkGroupItem.kt index ae4d06b0dc..c5af5a2cf6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkGroupItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/NetworkGroupItem.kt @@ -19,10 +19,7 @@ import org.burnoutcrew.reorderable.detectReorder @Composable internal fun NetworkGroupItem(networkName: String, modifier: Modifier = Modifier) { - InternalNetworkGroupItem( - modifier = modifier, - networkName = networkName, - ) + InternalNetworkGroupItem(modifier = modifier, networkName = networkName) } @Composable @@ -69,12 +66,13 @@ private fun InternalNetworkGroupItem( .background(TangemTheme.colors.background.primary) .padding(horizontal = TangemTheme.dimens.spacing14) .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size48), + .heightIn(min = TangemTheme.dimens.size40), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = stringResource(id = R.string.wallet_network_group_title, networkName), + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12, bottom = TangemTheme.dimens.spacing8), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt index 4cece938af..bb4427955e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt @@ -28,6 +28,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor( is WalletNotification.Warning.LowSignatures, is WalletNotification.Warning.NetworksUnreachable, is WalletNotification.Warning.SomeNetworksUnreachable, + is WalletNotification.SwapPromo, -> null } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/components/WalletManageButton.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/components/WalletManageButton.kt index fabf18401f..797aaa2711 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/components/WalletManageButton.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/components/WalletManageButton.kt @@ -88,7 +88,7 @@ internal sealed class WalletManageButton(val config: ActionButtonConfig) { */ data class Swap(override val enabled: Boolean, override val onClick: () -> Unit) : WalletManageButton( config = ActionButtonConfig( - text = TextReference.Res(id = R.string.common_swap), + text = TextReference.Res(id = R.string.swapping_swap_action), iconResId = R.drawable.ic_exchange_vertical_24, onClick = onClick, enabled = enabled, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/components/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/components/WalletNotification.kt index 331ccc1502..7f6bfd6b46 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/components/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/components/WalletNotification.kt @@ -166,4 +166,16 @@ sealed class WalletNotification(val config: NotificationConfig) { onCloseClick = onCloseClick, ), ) + + data class SwapPromo( + val onCloseClick: () -> Unit, + ) : WalletNotification( + config = NotificationConfig( + title = resourceReference(id = R.string.main_swap_promotion_title), + subtitle = resourceReference(id = R.string.main_swap_promotion_message), + iconResId = R.drawable.img_swap_promo, + backgroundResId = R.drawable.img_swap_promo_banner_background, + onCloseClick = onCloseClick, + ), + ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/factory/TokenActionsProvider.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/factory/TokenActionsProvider.kt index 735023102d..652a516847 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/factory/TokenActionsProvider.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/factory/TokenActionsProvider.kt @@ -78,7 +78,7 @@ internal class TokenActionsProvider( action = { clickIntents.onMultiCurrencySendClick(cryptoCurrencyStatus) } } is TokenActionsState.ActionState.Swap -> { - title = resourceReference(R.string.common_swap) + title = resourceReference(R.string.swapping_swap_action) icon = R.drawable.ic_exchange_horizontal_24 action = { clickIntents.onSwapClick(cryptoCurrencyStatus) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCurrencyActionsConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCurrencyActionsConverter.kt index 270fa05c9c..e43e0fb2a6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCurrencyActionsConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCurrencyActionsConverter.kt @@ -69,7 +69,7 @@ internal class MultiWalletCurrencyActionsConverter( action = { clickIntents.onSendClick(cryptoCurrencyStatus) } } is TokenActionsState.ActionState.Swap -> { - title = resourceReference(R.string.common_swap) + title = resourceReference(R.string.swapping_swap_action) icon = R.drawable.ic_exchange_horizontal_24 action = { clickIntents.onSwapClick(cryptoCurrencyStatus) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index 9e0f9f5123..f258a71ce9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.ui.Modifier import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationWithBackground import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.wallet.presentation.wallet.state.components.WalletNotification import kotlinx.collections.immutable.ImmutableList @@ -24,17 +25,24 @@ internal fun LazyListScope.notifications(configs: ImmutableList TangemTheme.colors.icon.warning - is WalletNotification.Informational -> TangemTheme.colors.icon.accent - is WalletNotification.RateApp -> TangemTheme.colors.icon.attention - is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 - is WalletNotification.Warning -> null - }, - ) + if (it is WalletNotification.SwapPromo) { + NotificationWithBackground( + config = it.config, + modifier = modifier.animateItemPlacement(), + ) + } else { + Notification( + config = it.config, + modifier = modifier.animateItemPlacement(), + iconTint = when (it) { + is WalletNotification.Critical -> TangemTheme.colors.icon.warning + is WalletNotification.Informational -> TangemTheme.colors.icon.accent + is WalletNotification.RateApp -> TangemTheme.colors.icon.attention + is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 + else -> null + }, + ) + } }, ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletClickIntents.kt index 5c390dcb0b..dad5efb741 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletClickIntents.kt @@ -72,4 +72,6 @@ internal interface WalletClickIntents { fun onExploreClick() fun onTransactionClick(txHash: String) + + fun onCloseSwapPromoNotificationClick() } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletNotificationsListFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletNotificationsListFactory.kt index 6540090c11..c0145c919b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletNotificationsListFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletNotificationsListFactory.kt @@ -5,6 +5,7 @@ import com.tangem.domain.common.CardTypesResolver import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.settings.IsReadyToShowRateAppUseCase +import com.tangem.domain.settings.ShouldShowSwapPromoWalletUseCase import com.tangem.domain.tokens.GetMissedAddressesCryptoCurrenciesUseCase import com.tangem.domain.tokens.error.GetCurrenciesError import com.tangem.domain.tokens.model.CryptoCurrency @@ -26,16 +27,21 @@ import kotlinx.coroutines.flow.flowOf * @property isDemoCardUseCase use case that checks if card is demo * @property isReadyToShowRateAppUseCase use case that checks if card is user already rate app * @property isNeedToBackupUseCase use case that checks if wallet need backup cards + * @property getMissedAddressCryptoCurrenciesUseCase use case that gets missed address crypto currencies + * @property hasSingleWalletSignedHashesUseCase use case that checks if single wallet signed hashes + * @property shouldShowSwapPromoWalletUseCase use case that checks if should show swap promo * @property clickIntents screen click intents * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") internal class WalletNotificationsListFactory( private val isDemoCardUseCase: IsDemoCardUseCase, private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, private val isNeedToBackupUseCase: IsNeedToBackupUseCase, private val getMissedAddressCryptoCurrenciesUseCase: GetMissedAddressesCryptoCurrenciesUseCase, private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase, + private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase, private val clickIntents: WalletClickIntents, ) { @@ -51,9 +57,12 @@ internal class WalletNotificationsListFactory( flow2 = isReadyToShowRateAppUseCase().conflate(), flow3 = isNeedToBackupUseCase(selectedWallet.walletId).conflate(), flow4 = getMissedAddressCryptoCurrenciesUseCase(selectedWallet.walletId).conflate(), - ) { hasSignedHashes, isReadyToShowRating, isNeedToBackup, maybeMissedAddressCurrencies -> + flow5 = shouldShowSwapPromoWalletUseCase().conflate(), + ) { hasSignedHashes, isReadyToShowRating, isNeedToBackup, maybeMissedAddressCurrencies, isShowSwapPromo -> readyForRateAppNotification = true buildList { + addSwapPromoNotification(isShowSwapPromo, cardTypesResolver) + addCriticalNotifications(cardTypesResolver) addInformationalNotifications(cardTypesResolver, maybeMissedAddressCurrencies) @@ -77,6 +86,18 @@ internal class WalletNotificationsListFactory( } } + private fun MutableList.addSwapPromoNotification( + showSwapPromo: Boolean, + cardTypesResolver: CardTypesResolver, + ) { + addIf( + element = WalletNotification.SwapPromo( + clickIntents::onCloseSwapPromoNotificationClick, + ), + condition = showSwapPromo && cardTypesResolver.isMultiwalletAllowed(), + ) + } + private fun MutableList.addCriticalNotifications(cardTypesResolver: CardTypesResolver) { addIf( element = WalletNotification.Critical.DevCard, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt index 6c025c1a77..4489a20d4c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt @@ -128,6 +128,7 @@ internal class WalletViewModel @Inject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val scanCardToUnlockWalletUseCase: ScanCardToUnlockWalletClickHandler, + private val shouldShowSwapPromoWalletUseCase: ShouldShowSwapPromoWalletUseCase, isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, isNeedToBackupUseCase: IsNeedToBackupUseCase, getMissedAddressesCryptoCurrenciesUseCase: GetMissedAddressesCryptoCurrenciesUseCase, @@ -146,6 +147,7 @@ internal class WalletViewModel @Inject constructor( isNeedToBackupUseCase = isNeedToBackupUseCase, getMissedAddressCryptoCurrenciesUseCase = getMissedAddressesCryptoCurrenciesUseCase, hasSingleWalletSignedHashesUseCase = hasSingleWalletSignedHashesUseCase, + shouldShowSwapPromoWalletUseCase = shouldShowSwapPromoWalletUseCase, clickIntents = this, ) @@ -673,7 +675,7 @@ internal class WalletViewModel @Inject constructor( .collectLatest { it.onRight { coinStatus -> uiState = stateFactory.getStateWithClosedBottomSheet() - reduxStateHolder.dispatch( + reduxStateHolder.dispatchWithMain( action = TradeCryptoAction.New.SendToken( userWallet = userWallet, tokenCurrency = requireNotNull(cryptoCurrencyStatus.currency as? CryptoCurrency.Token), @@ -774,6 +776,12 @@ internal class WalletViewModel @Inject constructor( refreshSingleCurrencyContent(selectedWalletIndex) } + override fun onCloseSwapPromoNotificationClick() { + viewModelScope.launch(dispatchers.main) { + shouldShowSwapPromoWalletUseCase.neverToShow() + } + } + // FIXME: refreshSingleCurrencyContent mustn't update the TxHistory and Buttons. It only must fetch primary // currency. Now it not works because GetPrimaryCurrency's subscriber uses .distinctUntilChanged() private fun refreshSingleCurrencyContent(walletIndex: Int) { @@ -1155,8 +1163,8 @@ internal class WalletViewModel @Inject constructor( ) } - private fun setupWalletConnectOnWallet(userWallet: UserWallet) { - reduxStateHolder.dispatch( + private suspend fun setupWalletConnectOnWallet(userWallet: UserWallet) { + reduxStateHolder.dispatchWithMain( action = WalletConnectActions.New.SetupUserChains(userWallet = userWallet), ) } diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 73d457f302..9d01214013 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -85,9 +85,9 @@ spr-client = "3.6.2" # endregion Other libraries # region Tangem -tangemBlockchainSdk = "develop-418" +tangemBlockchainSdk = "develop-440" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-312" +tangemCardSdk = "develop-316" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ # endregion Tangem diff --git a/libs/crypto/build.gradle.kts b/libs/crypto/build.gradle.kts index 248cc20bd1..60b1f6f10e 100644 --- a/libs/crypto/build.gradle.kts +++ b/libs/crypto/build.gradle.kts @@ -1,5 +1,8 @@ plugins { - alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) id("configuration") } @@ -7,4 +10,7 @@ dependencies { /** Coroutines */ implementation(deps.kotlin.coroutines) + + /** SDK */ + implementation(deps.tangem.blockchain) } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt index 3aa47352d5..ee8eb94937 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt @@ -1,5 +1,6 @@ package com.tangem.lib.crypto +import com.tangem.blockchain.common.TransactionExtras import com.tangem.lib.crypto.models.* import com.tangem.lib.crypto.models.transactions.SendTxResult import java.math.BigDecimal @@ -71,4 +72,7 @@ interface TransactionManager { @Throws(IllegalStateException::class) fun getExplorerTransactionLink(networkId: String, txAddress: String): String + + // TODO: move to another place to use as in Send feature + fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 08729b531c..edf4844e3e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -79,12 +79,16 @@ include(":libs:auth") // region Feature modules include(":features:onboarding") + include(":features:referral:data") include(":features:referral:domain") include(":features:referral:presentation") + include(":features:swap:api") include(":features:swap:data") include(":features:swap:domain") +include(":features:swap:domain:models") +include(":features:swap:domain:api") include(":features:swap:presentation") include(":features:tester:api")