diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 8dfb19bd16..ead5fca293 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -36,7 +36,6 @@ internal object TransactionDomainModule { isDemoCardUseCase = isDemoCardUseCase, cardSdkConfigRepository = cardSdkConfigRepository, walletManagersFacade = walletManagersFacade, - ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/BaseFragment.kt b/app/src/main/java/com/tangem/tap/features/BaseFragment.kt index e7aa8ff258..2c5ddc63f2 100644 --- a/app/src/main/java/com/tangem/tap/features/BaseFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/BaseFragment.kt @@ -75,9 +75,10 @@ interface FragmentOnBackPressedHandler { @SuppressLint("FragmentBackPressedCallback") fun Fragment.addBackPressHandler(handler: FragmentOnBackPressedHandler) { - requireActivity().onBackPressedDispatcher.addCallback { - handler.handleOnBackPressed() - } + requireActivity().onBackPressedDispatcher.addCallback( + owner = this, + onBackPressed = { handler.handleOnBackPressed() }, + ) view?.findViewById(R.id.toolbar)?.setNavigationOnClickListener { handler.handleOnBackPressed() diff --git a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt index 33a23f9750..3864885ded 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt @@ -9,11 +9,8 @@ import com.tangem.common.extensions.guard import com.tangem.core.analytics.Analytics import com.tangem.core.navigation.AppScreen import com.tangem.core.navigation.NavigationAction -import com.tangem.domain.common.extensions.toCoinId -import com.tangem.domain.common.extensions.toNetworkId import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.NetworkAddress import com.tangem.feature.swap.presentation.SwapFragment import com.tangem.features.send.api.navigation.SendRouter @@ -26,7 +23,6 @@ import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.dispatchOpenUrl import com.tangem.tap.common.redux.AppState import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.tokens.getIconUrl import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE import com.tangem.tap.features.send.redux.PrepareSendScreen @@ -41,9 +37,6 @@ import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope import com.tangem.tap.store import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import com.tangem.feature.swap.domain.models.domain.Currency as SwapCurrency @Suppress("LargeClass") class TradeCryptoMiddleware { @@ -58,17 +51,12 @@ class TradeCryptoMiddleware { is TradeCryptoAction.SendCrypto -> preconfigureAndOpenSendScreen(action) is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId) is TradeCryptoAction.Swap -> { - openSwap( - currency = store.state.walletState.selectedWalletData?.currency?.toSwapCurrency(), - derivationPath = store.state.walletState.selectedWalletData?.currency?.derivationPath, - ) + // todo remove old flow } is TradeCryptoAction.New.Buy -> proceedNewBuyAction(state, action) is TradeCryptoAction.New.Sell -> proceedNewSellAction(action) is TradeCryptoAction.New.Swap -> openSwap( - currency = action.cryptoCurrency.toSwapCurrency(), - derivationPath = action.cryptoCurrency.network.derivationPath.value, - network = action.cryptoCurrency.network, + currency = action.cryptoCurrency, ) is TradeCryptoAction.New.SendToken -> handleNewSendToken(action = action) is TradeCryptoAction.New.SendCoin -> handleNewSendCoin(action = action) @@ -274,70 +262,14 @@ class TradeCryptoMiddleware { )?.let { store.dispatchOpenUrl(it) } } - private fun openSwap(currency: SwapCurrency?, derivationPath: String?, network: Network? = null) { + private fun openSwap(currency: CryptoCurrency) { val bundle = bundleOf( - SwapFragment.CURRENCY_BUNDLE_KEY to Json.encodeToString(currency), - SwapFragment.DERIVATION_PATH to derivationPath, - SwapFragment.NETWORK to network, + SwapFragment.CURRENCY_BUNDLE_KEY to currency, ) store.dispatchOnMain(NavigationAction.NavigateTo(screen = AppScreen.Swap, bundle = bundle)) } - private fun CryptoCurrency.toSwapCurrency(): SwapCurrency { - val blockchain = Blockchain.fromId(network.id.value) - - return when (this) { - is CryptoCurrency.Coin -> { - SwapCurrency.NativeToken( - id = blockchain.toCoinId(), - name = name, - symbol = symbol, - networkId = blockchain.toNetworkId(), - // no need to set logoUrl for blockchain cause - // error when form url with coinId, coinId of eth and arbitrum the same - logoUrl = "", - ) - } - is CryptoCurrency.Token -> { - SwapCurrency.NonNativeToken( - id = id.rawCurrencyId ?: "", - name = name, - symbol = symbol, - networkId = blockchain.toNetworkId(), - logoUrl = getIconUrl(id.rawCurrencyId ?: ""), - contractAddress = contractAddress, - decimalCount = decimals, - ) - } - } - } - - private fun Currency.toSwapCurrency(): SwapCurrency { - return when (this) { - is Currency.Blockchain -> { - SwapCurrency.NativeToken( - id = blockchain.toCoinId(), - name = this.currencyName, - symbol = this.currencySymbol, - networkId = this.blockchain.toNetworkId(), - // no need to set logoUrl for blockchain cause - // error when form url with coinId, coinId of eth and arbitrum the same - logoUrl = "", - ) - } - is Currency.Token -> SwapCurrency.NonNativeToken( - id = this.token.id ?: "", - name = this.currencyName, - symbol = this.currencySymbol, - networkId = this.blockchain.toNetworkId(), - logoUrl = getIconUrl(this.token.id ?: ""), - contractAddress = this.token.contractAddress, - decimalCount = decimals, - ) - } - } - private fun handleNewSendToken(action: TradeCryptoAction.New.SendToken) { val currency = action.tokenCurrency val blockchain = Blockchain.fromId(currency.network.id.value) diff --git a/app/src/main/java/com/tangem/tap/network/auth/ExpressAuthProviderImpl.kt b/app/src/main/java/com/tangem/tap/network/auth/ExpressAuthProviderImpl.kt new file mode 100644 index 0000000000..2a7f65eee3 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/network/auth/ExpressAuthProviderImpl.kt @@ -0,0 +1,31 @@ +package com.tangem.tap.network.auth + +import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.lib.auth.sessionId.ExpressSessionIdGenerator +import java.util.UUID + +class ExpressAuthProviderImpl( + private val userWalletsStore: UserWalletsStore, + private val configManager: ConfigManager, +) : ExpressAuthProvider, ExpressSessionIdGenerator { + + private var uuid = UUID.randomUUID() + + override fun getApiKey(): String { + return configManager.config.tangemExpressApiKey + } + + override fun getUserId(): String { + return userWalletsStore.selectedUserWalletOrNull?.walletId?.stringValue ?: "" + } + + override fun getSessionId(): String { + return uuid.toString() + } + + override fun generateNewSessionId() { + uuid = UUID.randomUUID() + } +} \ No newline at end of file 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 b05033931a..33823b5d36 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -224,20 +224,18 @@ class TransactionManagerImpl( // for not EVM blockchains set gasLimit ZERO for now when (fee.data) { is TransactionFee.Single -> { - val fee = (fee.data as TransactionFee.Single).normal + val normalFee = (fee.data as TransactionFee.Single).normal val singleFee = ProxyFee( gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = fee.amount), + fee = convertToProxyAmount(amount = normalFee.amount), ) - ProxyFees( - minFee = singleFee, - normalFee = singleFee, - priorityFee = singleFee, + ProxyFees.SingleFee( + singleFee = singleFee, ) } is TransactionFee.Choosable -> { val choosableFee = fee.data as TransactionFee.Choosable - ProxyFees( + ProxyFees.MultipleFees( minFee = ProxyFee( gasLimit = BigInteger.ZERO, fee = convertToProxyAmount(amount = choosableFee.minimum.amount), @@ -280,7 +278,7 @@ class TransactionManagerImpl( ).increaseBigIntegerByPercents(increaseBy) return when (val gasPrice = walletManager.getGasPrice()) { is Result.Success -> { - createProxyFees(gasPrice = gasPrice.data, gasLimit = gasLimit, blockchain = blockchain) + createMultipleProxyFees(gasPrice = gasPrice.data, gasLimit = gasLimit, blockchain = blockchain) } is Result.Failure -> { error(gasPrice.error.message ?: gasPrice.error.customMessage) @@ -316,7 +314,7 @@ class TransactionManagerImpl( fee = convertToProxyAmount(amount = choosableFee.priority.amount), ) - ProxyFees( + ProxyFees.MultipleFees( minFee = minProxyFee, normalFee = normalProxyFee, priorityFee = priorityProxyFee, @@ -456,7 +454,7 @@ class TransactionManagerImpl( * @param gasLimit * @param blockchain */ - private fun createProxyFees(gasPrice: BigInteger, gasLimit: BigInteger, blockchain: Blockchain): ProxyFees { + private fun createMultipleProxyFees(gasPrice: BigInteger, gasLimit: BigInteger, blockchain: Blockchain): ProxyFees { val gasPriceNormal = gasPrice.increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_NORMAL_FEE) val gasPricePriority = gasPrice.increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_PRIORITY_FEE) val feeMin = gasLimit.multiply(gasPrice).toBigDecimal( @@ -495,7 +493,7 @@ class TransactionManagerImpl( decimals = blockchain.decimals(), ), ) - return ProxyFees( + return ProxyFees.MultipleFees( minFee = minFee, normalFee = normalFee, priorityFee = priorityFee, diff --git a/app/src/main/res/layout/layout_send_receipt.xml b/app/src/main/res/layout/layout_send_receipt.xml index 4a8fab6816..97e7afbb0f 100644 --- a/app/src/main/res/layout/layout_send_receipt.xml +++ b/app/src/main/res/layout/layout_send_receipt.xml @@ -33,7 +33,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:text="@string/send_fee_label" + android:text="@string/common_fee_label" android:textColor="@color/text_tertiary" android:textStyle="bold" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/layout_single_wallet_balance.xml b/app/src/main/res/layout/layout_single_wallet_balance.xml index c3a75693bc..d3c6de4ff3 100644 --- a/app/src/main/res/layout/layout_single_wallet_balance.xml +++ b/app/src/main/res/layout/layout_single_wallet_balance.xml @@ -21,7 +21,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:text="@string/onboarding_balance_title" + android:text="@string/common_balance_title" android:textColor="@color/text_tertiary" android:textSize="14sp" android:textStyle="bold" diff --git a/app/src/main/res/layout/view_onboarding_tv_balance.xml b/app/src/main/res/layout/view_onboarding_tv_balance.xml index f421e4d992..f84e954980 100644 --- a/app/src/main/res/layout/view_onboarding_tv_balance.xml +++ b/app/src/main/res/layout/view_onboarding_tv_balance.xml @@ -12,7 +12,7 @@ android:elevation="0dp" android:fontFamily="sans-serif-medium" android:letterSpacing="0.036" - android:text="@string/onboarding_balance_title" + android:text="@string/common_balance_title" android:textAllCaps="true" android:textColor="@color/text_secondary" android:textSize="14sp" diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt index 5e88155058..e342289f0c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt @@ -12,7 +12,11 @@ sealed class ApiResponseError : Exception() { * @property code The HTTP status code. * @property message A human-readable message describing the error. */ - data class HttpException(val code: Code, override val message: String?) : ApiResponseError() { + data class HttpException( + val code: Code, + override val message: String?, + val errorBody: String?, + ) : ApiResponseError() { // region Error Codes enum class Code(val code: Int) { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt index 0ddb0ffa36..718392b0d9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt @@ -16,7 +16,7 @@ internal fun Response.toSafeApiResponse(): ApiResponse { val e = if (code == null) { ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}")) } else { - ApiResponseError.HttpException(code, message()) + ApiResponseError.HttpException(code, message(), errorBody()?.string()) } apiError(e) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/ExpressApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt similarity index 70% rename from core/datasource/src/main/java/com/tangem/datasource/api/express/ExpressApi.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt index 0ed82c76d9..b78d485fa6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/ExpressApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt @@ -8,13 +8,12 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Query -import java.math.BigDecimal /** * Interface of Tangem Express API (new swap mechanism) */ @Suppress("LongParameterList") -interface ExpressApi { +interface TangemExpressApi { @POST("assets") suspend fun getAssets(@Body body: AssetsRequestBody): ApiResponse> @@ -31,9 +30,11 @@ interface ExpressApi { @Query("fromNetwork") fromNetwork: String, @Query("toContractAddress") toContractAddress: String, @Query("toNetwork") toNetwork: String, - @Query("fromAmount") fromAmount: BigDecimal, - @Query("providerId") providerId: Int, - @Query("rateType") rateType: RateType, + @Query("fromAmount") fromAmount: String, + @Query("fromDecimals") fromDecimals: Int, + @Query("toDecimals") toDecimals: Int, + @Query("providerId") providerId: String, + @Query("rateType") rateType: String, ): ApiResponse @GET("exchange-data") @@ -42,12 +43,14 @@ interface ExpressApi { @Query("fromNetwork") fromNetwork: String, @Query("toContractAddress") toContractAddress: String, @Query("toNetwork") toNetwork: String, - @Query("fromAmount") fromAmount: BigDecimal, - @Query("providerId") providerId: Int, - @Query("rateType") rateType: RateType, + @Query("fromAmount") fromAmount: String, + @Query("fromDecimals") fromDecimals: Int, + @Query("toDecimals") toDecimals: Int, + @Query("providerId") providerId: String, + @Query("rateType") rateType: String, @Query("toAddress") toAddress: String, ): ApiResponse - @GET("exchange-result") - suspend fun getExchangeResults(@Query("txId") txId: String): ApiResponse + @GET("exchange-status") + suspend fun getExchangeStatus(@Query("txId") txId: String): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt new file mode 100644 index 0000000000..984184791c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt @@ -0,0 +1,5 @@ +package com.tangem.datasource.api.express.models + +object TangemExpressValues { + const val EMPTY_CONTRACT_ADDRESS_VALUE = "0" +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/request/AssetsRequestBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/request/AssetsRequestBody.kt index f1d31bd24e..2d496a94ec 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/request/AssetsRequestBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/request/AssetsRequestBody.kt @@ -3,5 +3,5 @@ package com.tangem.datasource.api.express.models.request import com.squareup.moshi.Json data class AssetsRequestBody( - @Json(name = "filter") val filter: List?, + @Json(name = "tokensList") val tokensList: List?, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/Asset.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/Asset.kt index dc2fd581fe..87f83ea98f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/Asset.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/Asset.kt @@ -9,21 +9,6 @@ data class Asset( @Json(name = "network") val network: String, - @Json(name = "token") - val token: String, - - @Json(name = "name") - val name: String, - - @Json(name = "symbol") - val symbol: String, - - @Json(name = "decimals") - val decimals: Int, - - @Json(name = "isActive") - val isActive: Boolean, - @Json(name = "exchangeAvailable") val exchangeAvailable: Boolean, ) \ No newline at end of file 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 3083bdfd0b..72deee1a57 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 @@ -4,8 +4,17 @@ import com.squareup.moshi.Json import java.math.BigDecimal data class ExchangeDataResponse( + @Json(name = "fromAmount") + val fromAmount: String, + + @Json(name = "fromDecimals") + val fromDecimals: Int, + @Json(name = "toAmount") - val toAmount: BigDecimal, + val toAmount: String, + + @Json(name = "toDecimals") + val toDecimals: Int, @Json(name = "txType") val txType: 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 934d1a8497..4e2bc6d69c 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 @@ -4,19 +4,19 @@ import com.squareup.moshi.Json data class ExchangeProvider( @Json(name = "id") - val id: Int, + val id: String, @Json(name = "name") val name: String, - @Json(name = "id") + @Json(name = "type") val type: ExchangeProviderType, @Json(name = "imageLarge") - val imageLargeUrl: Int, + val imageLargeUrl: String, @Json(name = "imageSmall") - val imageSmallUrl: Int, + val imageSmallUrl: String, ) enum class ExchangeProviderType { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt index 214b4ac2d5..e643811822 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt @@ -4,9 +4,23 @@ import com.squareup.moshi.Json import java.math.BigDecimal data class ExchangeQuoteResponse( + + @Json(name = "fromAmount") + val fromAmount: String, + + @Json(name = "fromDecimals") + val fromDecimals: Int, + @Json(name = "toAmount") - val toAmount: BigDecimal, + val toAmount: String, + + @Json(name = "toDecimals") + val toDecimals: Int, @Json(name = "allowanceContract") val allowanceContract: String?, + + @Json(name = "minAmount") + val minAmount: BigDecimal, + ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeResultsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeResultsResponse.kt deleted file mode 100644 index c9b10f881e..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeResultsResponse.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.datasource.api.express.models.response - -import com.squareup.moshi.Json - -data class ExchangeResultsResponse( - @Json(name = "status") - val status: ExchangeResultsStatus, - - @Json(name = "externalStatus") - val externalStatus: String, - - @Json(name = "externalTxUrl") - val externalTxUrl: String, - - @Json(name = "error") - val error: ExchangeResultsError?, -) - -enum class ExchangeResultsStatus { - @Json(name = "processing") - PROCESSING, - - @Json(name = "done") - DONE, - - @Json(name = "failed") - FAILED, - - @Json(name = "refunded") - REFUNDED, - - @Json(name = "verificationRequired") - VERIFICATION_REQUIRED, -} - -data class ExchangeResultsError( - @Json(name = "code") - val code: Int, - - @Json(name = "description") - val description: String, -) \ No newline at end of file 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 new file mode 100644 index 0000000000..94cd9cbf1f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeStatusResponse.kt @@ -0,0 +1,59 @@ +package com.tangem.datasource.api.express.models.response + +import com.squareup.moshi.Json + +data class ExchangeStatusResponse( + + @Json(name = "providerId") + val providerId: String, + + @Json(name = "externalTxId") + val externalTxId: String, + + @Json(name = "externalTxStatus") + val externalStatus: ExchangeStatus, + + @Json(name = "externalTxUrl") + val externalTxUrl: String, + + @Json(name = "error") + val error: ExchangeStatusError?, +) + +enum class ExchangeStatus { + + @Json(name = "new") + NEW, + + @Json(name = "waiting") + WAITING, + + @Json(name = "confirming") + CONFIRMING, + + @Json(name = "exchanging") + EXCHANGING, + + @Json(name = "sending") + SENDING, + + @Json(name = "finished") + FINISHED, + + @Json(name = "failed") + FAILED, + + @Json(name = "refunded") + REFUNDED, + + @Json(name = "verifying") + VERIFYING, +} + +data class ExchangeStatusError( + @Json(name = "code") + val code: Int, + + @Json(name = "description") + val description: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt new file mode 100644 index 0000000000..ea8a552360 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt @@ -0,0 +1,37 @@ +package com.tangem.datasource.api.express.models.response + +import com.squareup.moshi.Json +import java.math.BigDecimal + +data class ExpressErrorResponse( + @Json(name = "error") + val error: ExpressError, +) + +data class ExpressError( + @Json(name = "code") + val code: Int, + + @Json(name = "description") + val description: String?, + + @Json(name = "value") + val value: ExpressErrorValue?, +) + +data class ExpressErrorValue( + @Json(name = "minAmount") + val minAmount: String?, + + @Json(name = "decimals") + val decimals: Int?, + + @Json(name = "currentAllowance") + val currentAllowance: BigDecimal?, + + @Json(name = "receivedFromDecimals") + val receivedFromDecimals: Int?, + + @Json(name = "expressFromDecimals") + val expressFromDecimals: Int?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPair.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPair.kt index ba04207f85..f96aef7dfc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPair.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPair.kt @@ -17,10 +17,10 @@ data class SwapPair( data class SwapPairProvider( @Json(name = "providerId") - val providerId: Int, + val providerId: String, - @Json(name = "rateType") - val rateType: RateType, + @Json(name = "rateTypes") + val rateTypes: List, ) enum class RateType { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPairsWithProviders.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPairsWithProviders.kt new file mode 100644 index 0000000000..41568fb92f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPairsWithProviders.kt @@ -0,0 +1,6 @@ +package com.tangem.datasource.api.express.models.response + +class SwapPairsWithProviders( + val swapPair: List, + val providers: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MarketCoinsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AssetsStoreModule.kt similarity index 51% rename from core/datasource/src/main/java/com/tangem/datasource/di/MarketCoinsStoreModule.kt rename to core/datasource/src/main/java/com/tangem/datasource/di/AssetsStoreModule.kt index bd2098a670..43b8e93f5c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MarketCoinsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AssetsStoreModule.kt @@ -1,8 +1,8 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeDataStore -import com.tangem.datasource.local.token.DefaultUserMarketCoinsStore -import com.tangem.datasource.local.token.UserMarketCoinsStore +import com.tangem.datasource.local.token.DefaultAssetsStore +import com.tangem.datasource.local.token.AssetsStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -11,11 +11,11 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object MarketCoinsStoreModule { +internal object AssetsStoreModule { @Provides @Singleton - fun provideUserMarketCoinsStore(): UserMarketCoinsStore { - return DefaultUserMarketCoinsStore(dataStore = RuntimeDataStore()) + fun provideAssetsStore(): AssetsStore { + return DefaultAssetsStore(dataStore = RuntimeDataStore()) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index d8b4a287eb..74791c9c0c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -3,7 +3,7 @@ package com.tangem.datasource.di import android.content.Context import com.squareup.moshi.Moshi import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory -import com.tangem.datasource.api.express.ExpressApi +import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.promotion.PromotionApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.utils.RequestHeader.* @@ -32,7 +32,7 @@ class NetworkModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, expressAuthProvider: ExpressAuthProvider, - ): ExpressApi { + ): TangemExpressApi { return Retrofit.Builder() .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) @@ -44,7 +44,7 @@ class NetworkModule { .build(), ) .build() - .create(ExpressApi::class.java) + .create(TangemExpressApi::class.java) } @Provides diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/SwapTransactionStatusStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/SwapTransactionStatusStoreModule.kt new file mode 100644 index 0000000000..25f71c60a0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/SwapTransactionStatusStoreModule.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.swaptx.DefaultSwapTransactionStatusStore +import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SwapTransactionStatusStoreModule { + + @Provides + @Singleton + fun provideSwapTransactionStatusStore(): SwapTransactionStatusStore { + return DefaultSwapTransactionStatusStore( + dataStore = RuntimeDataStore(), + ) + } +} \ No newline at end of file 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 916027a8aa..d10a9dab4f 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 @@ -33,11 +33,15 @@ object PreferencesKeys { val BALANCE_HIDING_SETTINGS_KEY by lazy { stringPreferencesKey(name = "balanceHidingSettings") } + val SWAP_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "swapTransactions") } + val WALLETS_SCROLL_PREVIEW_KEY by lazy { booleanPreferencesKey(name = "walletsScrollPreview") } val SENT_ONE_TIME_EVENTS_KEY by lazy { stringPreferencesKey(name = "sentOneTimeEvents") } val WALLETS_BALANCES_STATES_KEY by lazy { stringPreferencesKey(name = "walletsBalancesStates") } + + val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") } } /** 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/swaptx/DefaultSwapTransactionStatusStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/DefaultSwapTransactionStatusStore.kt new file mode 100644 index 0000000000..50b4f08c9d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/DefaultSwapTransactionStatusStore.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.local.swaptx + +import com.tangem.datasource.local.datastore.core.StringKeyDataStore + +internal class DefaultSwapTransactionStatusStore( + private val dataStore: StringKeyDataStore, +) : SwapTransactionStatusStore, StringKeyDataStore by dataStore { + + override suspend fun getTransactionStatus(txId: String) = getSyncOrNull(txId) + + override suspend fun setTransactionStatus(txId: String, status: ExchangeAnalyticsStatus) = store(txId, status) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..59bd30030e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/SwapTransactionStatusStore.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.local.swaptx + +/** + * Runtime cache for storing swap transactions statuses sent to analytics + */ +interface SwapTransactionStatusStore { + suspend fun getTransactionStatus(txId: String): ExchangeAnalyticsStatus? + + suspend fun setTransactionStatus(txId: String, status: ExchangeAnalyticsStatus) +} + +enum class ExchangeAnalyticsStatus(val value: String) { + InProgress("In Progress"), + Done("Done"), + Fail("Fail"), + KYC("KYC"), + Refunded("Refunded"), +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/AssetsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/AssetsStore.kt new file mode 100644 index 0000000000..11d6101843 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/AssetsStore.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.local.token + +import com.tangem.datasource.api.express.models.response.Asset +import com.tangem.domain.wallets.models.UserWalletId + +interface AssetsStore { + + suspend fun getSyncOrNull(userWalletId: UserWalletId): List? + + suspend fun store(userWalletId: UserWalletId, item: List) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserMarketCoinsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultAssetsStore.kt similarity index 64% rename from core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserMarketCoinsStore.kt rename to core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultAssetsStore.kt index c08a627df9..9177ec70b7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserMarketCoinsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultAssetsStore.kt @@ -1,18 +1,18 @@ package com.tangem.datasource.local.token -import com.tangem.datasource.api.tangemTech.models.CoinsResponse +import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.local.datastore.core.StringKeyDataStore import com.tangem.domain.wallets.models.UserWalletId -internal class DefaultUserMarketCoinsStore( - private val dataStore: StringKeyDataStore, -) : UserMarketCoinsStore { +internal class DefaultAssetsStore( + private val dataStore: StringKeyDataStore>, +) : AssetsStore { - override suspend fun getSyncOrNull(userWalletId: UserWalletId): CoinsResponse? { + override suspend fun getSyncOrNull(userWalletId: UserWalletId): List? { return dataStore.getSyncOrNull(userWalletId.stringValue) } - override suspend fun store(userWalletId: UserWalletId, item: CoinsResponse) { + override suspend fun store(userWalletId: UserWalletId, item: List) { dataStore.store(userWalletId.stringValue, item) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserMarketCoinsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserMarketCoinsStore.kt deleted file mode 100644 index 10895699ac..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserMarketCoinsStore.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.datasource.local.token - -import com.tangem.datasource.api.tangemTech.models.CoinsResponse -import com.tangem.domain.wallets.models.UserWalletId - -interface UserMarketCoinsStore { - - suspend fun getSyncOrNull(userWalletId: UserWalletId): CoinsResponse? - - suspend fun store(userWalletId: UserWalletId, item: CoinsResponse) -} \ No newline at end of file diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 3bc3defd6d..ddbf073872 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -13,6 +13,7 @@ Änderungen speichern Absenden Erfolg + Gebühr Zugangscode Sie müssen den richtigen Zugangscode eingeben, bevor Sie die Karte scannen. Langes Tippen @@ -40,7 +41,6 @@ Tag Memo inkl. Gebühr - Gebühr Niedrig Normal Priorität diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 097c597e6e..9ac49cb6d8 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -13,6 +13,7 @@ Sauvegarder les modifications Envoyer Avec succès + Commissions Code d\'accès Vous devrez entrer le mot de passe correct avant de scanner la carte Tenez la carte fermement @@ -40,7 +41,6 @@ Tag Memo Inclure les commissions - Commissions Bas Normal Priorité diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index aa5a2b3d47..f673e3bf38 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -13,6 +13,7 @@ Mantieni le modifiche Invia Con successo + Commissione Codice di accesso Prima di scansionare la carta sarà necessario inserire il codice di accesso corretto Mantenimento della carta @@ -40,7 +41,6 @@ Tag Memo Includi commissione - Commissione Insufficiente Normale Prioritario diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 2e854b1d6f..6fe946df8b 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -66,6 +66,7 @@ Одобрение Внимание Баланс: %s + Баланс биометрическую аутентификацию биометрией Купить @@ -85,9 +86,11 @@ Включено Ошибка Обменять - Посмотреть историю транзакций Обозреватель + Посмотреть историю + Посмотреть историю транзакций Обозреватель + Комиссия Сетевые комиссии за транзакции используются для поддержки безопасности сети, поощрения валидаторов, выделения ресурсов и определения приоритета транзакции. Свое Быстро @@ -191,9 +194,19 @@ Мои токены У вас нет добавленных токенов. Добавьте токены для обмена Недоступен для обмена с %s + Статус Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами Выберите провайдера + Чтобы узнать причину, посетите сайт провайдера + Чтобы вернуть ваши деньги, посетите сайт провайдера + Посетите сайт провайдера для проверки + Провайдер запрашивает прохождение верификации + Список токенов в вашем кошельке + Получение наилучших курсов... + Провайдер Лучший курс + Доступно с %s + Недоступно для этой пары Требуется разрешение Информация ниже не является обязательной. Вы можете стереть её, если хотите. Расскажите, каких функций вам не хватает, и мы постараемся вам помочь. @@ -267,7 +280,6 @@ Введенные коды доступа не совпадают Вы добавили одну резервную карту. После того, как процесс будет завершен, Вы больше не сможете добавить карт. Если у Вас есть еще одна карта, добавьте ее в резервную копию. Хотите продолжить? Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас. - Баланс Добавить резервную карту Сканировать карту #%d Создать резервную копию @@ -418,7 +430,6 @@ Tag Memo Включая комиссию - Комиссия Низкая Нормальная Приоритетная @@ -629,12 +640,18 @@ Нравится Понятно! Очень круто! + Обновить Вы находитесь в режиме демо Демо режим включен Отсканированная вами карта является картой разработчика. Не используйте ее для создания своего кошелька. Не для пользователя! Cеть %1$s использует концепцию экзистенциального депозита. Если баланс вашего счета будет ниже %2$s, то он будет деактивирован, а средства на счете уничтожены. Для работы с сетью необходим депозит + У вас в списке нет монет доступных для обмена с %s + Нет доступных токенов для обмена + Cервис временно недоступен + Пожалуйста, измените сумму для обмена + Сумма для обмена должна быть не менее %s Возможно, данная карта - образец или подделка Ошибка проверки подлинности На этой карте осталось всего %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 b80f9ce410..f595e68773 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -90,6 +90,7 @@ 提交 成功 交換 + 費用 條款和條件 交易 我了解 @@ -178,7 +179,7 @@ 輸入的訪問密碼與初始訪問密碼不匹配 您已添加一張備用卡。備份過程完成後,您將無法添加更多備份卡。如果您還有一張卡,請將其添加到備份中。您想繼續備份過程嗎? 備份過程已部分完成。你現在不能退出 - 餘額 + 餘額 添加備用卡 掃描卡片 #%d 立即備份 @@ -294,7 +295,6 @@ Tag Memo 包含費用 - 費用 正常 優先 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index f5fdebf47a..345f06a0f6 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -64,6 +64,7 @@ Approval Attention Balance: %s + Balance biometric authentication biometrics Buy @@ -84,8 +85,10 @@ Error Exchange Explore + Explore history Explore transaction history Explorer + Fee Network transaction fees are used to support network security, incentivize validators, allocate resources, and determine transaction priority. Custom Fast @@ -185,14 +188,46 @@ 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 + You receive + You send My tokens - You don\'t have any added tokens yet. Add tokens via Market to swap - Unavailable for swap from %s - Providers facilitate transactions, ensuring smooth and efficient token exchanges - Choose Provider - Best Rate + You haven\'t added any tokens yet. Add tokens via Market to swap + Cannot be swapped for %s + Status + Providers facilitate transactions, ensuring smooth and efficient token swaps + Choose provider + 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 + Confirmed + Confirming + Confirming… + Exchanged + Exchanging + Exchanging… + Failed + Deposit received + Awaiting deposit + Awaiting deposit… + Refunded + Sending to you + Sending to you… + Sent + Provider-sourced data. Estimated amount subject to change. + Exchange status + Verified + Verification required + List of all tokens added to your wallet + Fetching best rates... + Floating rate + Go to provider + Provider + Best rate + Available from %s + Unavailable for this pair Permission Needed 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. @@ -267,7 +302,6 @@ Entered access code didn\'t match the initial access code You\'ve added one backup card. When backup process is finished you can\'t add more backup cards. If you have one more card, add it to backup. Do you like to continue the backup process? The backup process is partly complete. You can\'t exit it now. - Balance Add a backup card Scan the card #%d Backup now @@ -413,9 +447,7 @@ Invalid Memo. It won\'t be added to the transaction. Tag Memo - Insufficient funds for transfer Include fee - Fee Low Normal Priority @@ -490,7 +522,7 @@ Meet Tangem Exchange, buy NFT\'s, make loans and deposits in more than 100 different decentralized services Web 3.0 Compatible - Approvals are considered an industry standard across all decentralized exchanges and protect your wallet from being accessed by a smart contract without your permission. By design, smart contracts can\'t access your tokens unless you approve access from your end. By \"unlocking\" your tokens, you are give permission to the 1inch smart contract to spend your assets. The miners of the network are compensated with a gas fee (paid by you) to record this action on the blockchain. Once permission has been granted you will be able to swap your token. + 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 There was an error. Please try again. @@ -498,23 +530,25 @@ High price impact! Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome. Insufficient funds - Not enough funds for fee in your %1$s wallet to create a transaction. Top up your %2$s wallet first. + Insufficient funds in your %1$s wallet to cover fees. Top up your %2$s wallet first. Transaction in progress... Waiting Approve Current transaction - The token approval network fee will be charged to confirm that you are the one allowing your token to be used for the exchange. + The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap. Give Permission Specify the approve limit for the selected token Amount %s Spender - Your Wallet - To continue you need to allow 1inch smart contracts to use your %s + 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 of %s to + Swap %s for Quotes include an additional Tangem commission of %s. This helps us deliver a top-of-the-line product. Other tokens Choose token @@ -636,12 +670,20 @@ Like it Ok, Got it! Really cool! + Refresh You are currently in the Demo mode Demo mode active The card you scanned is a developer card. Do not use it to create your wallet. 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 + 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 + Please change the amount to swap + The amount to swap must be at least %s This card might be a production sample or counterfeit Authenticity check failed Only %s signatures are left on this card. You must withdraw all of your funds. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt index 9bdff5f987..e67fbf3de2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.text.TextLayoutResult @@ -18,10 +17,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.TextUnit +import com.tangem.core.ui.components.atoms.text.BoundCounter /** * https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose */ +@Deprecated("Use EllipsisText with TextEllipsis.Middle ellipsis instead") @Suppress("LongMethod") @Composable fun MiddleEllipsisText( @@ -138,38 +139,4 @@ fun MiddleEllipsisText( private const val ELLIPSIS_CHARACTERS_COUNT = 3 private const val ELLIPSIS_CHARACTER = '.' -private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "") - -private class BoundCounter( - private val text: String, - private val textLayoutResult: TextLayoutResult, - private val charPosition: (Int) -> Int, -) { - var string = "" - private set - var width = 0f - private set - - private var _nextCharWidth: Float? = null - private var invalidCharsCount = 0 - - fun widthWithNextChar(): Float = width + nextCharWidth() - - private fun nextCharWidth(): Float = _nextCharWidth ?: run { - var boundingBox: Rect - // invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630 - invalidCharsCount-- - do { - boundingBox = textLayoutResult - .getBoundingBox(charPosition(string.count() + ++invalidCharsCount)) - } while (boundingBox.right == 0f) - _nextCharWidth = boundingBox.width - boundingBox.width - } - - fun addNextChar() { - string += text[charPosition(string.count())] - width += nextCharWidth() - _nextCharWidth = null - } -} \ No newline at end of file +private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "") \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/SuccessScreen.kt b/core/ui/src/main/java/com/tangem/core/ui/components/SuccessScreen.kt deleted file mode 100644 index c5c3feabc1..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/SuccessScreen.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.tangem.core.ui.components - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -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.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.res.TangemTheme - -/** - * Screen for showing result - * - * @param resultMessage message to show - * @param title title to show - * @param resultColor color which will tint the round icon of the result - * @param icon icon to show in the middle of the round icon - * @param secondaryButtonIcon icon to show in the secondary button - * @param secondaryButtonText label of the secondary button - * @param onSecondaryButtonClick action on clicking secondary button - * @param onButtonClick action on clicking "Done" button - * - * @see Figma component - */ -@Composable -fun ResultScreenContent( - resultMessage: AnnotatedString, - onButtonClick: () -> Unit, - modifier: Modifier = Modifier, - @StringRes title: Int = R.string.common_success, - resultColor: Color = TangemTheme.colors.icon.accent, - @DrawableRes icon: Int = R.drawable.ic_check_24, - @DrawableRes secondaryButtonIcon: Int? = null, - @StringRes secondaryButtonText: Int? = null, - onSecondaryButtonClick: (() -> Unit)? = null, -) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .background(TangemTheme.colors.background.secondary) - .padding( - horizontal = TangemTheme.dimens.spacing16, - vertical = TangemTheme.dimens.spacing32, - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - SpacerHHalf() - SuccessImage(resultColor = resultColor, icon = icon) - SpacerH50() - Text( - text = stringResource(id = title), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - SpacerH12() - Text( - text = resultMessage, - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.secondary, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - SpacerHHalf() - if (onSecondaryButtonClick != null && secondaryButtonText != null) { - SecondaryButtonForResultScreen( - secondaryButtonText = secondaryButtonText, - secondaryButtonIcon = secondaryButtonIcon, - onSecondaryButtonClick = onSecondaryButtonClick, - ) - SpacerH12() - } - PrimaryButton( - text = stringResource(id = R.string.common_close), - modifier = Modifier - .fillMaxWidth(), - onClick = { onButtonClick() }, - ) - } -} - -@Composable -fun SuccessImage(resultColor: Color, @DrawableRes icon: Int) { - Box( - modifier = Modifier - .background( - color = resultColor.copy(alpha = 0.2f), - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .padding(TangemTheme.dimens.spacing24) - .background( - color = resultColor, - shape = CircleShape, - ) - .height(TangemTheme.dimens.size93) - .width(TangemTheme.dimens.size93), - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = icon), - contentDescription = null, - tint = TangemTheme.colors.icon.primary2, - modifier = Modifier.size(TangemTheme.dimens.size40), - ) - } - } -} - -@Composable -private fun SecondaryButtonForResultScreen( - @StringRes secondaryButtonText: Int, - onSecondaryButtonClick: () -> Unit, - @DrawableRes secondaryButtonIcon: Int? = null, -) { - if (secondaryButtonIcon != null) { - SecondaryButtonIconStart( - text = stringResource(id = secondaryButtonText), - iconResId = secondaryButtonIcon, - onClick = onSecondaryButtonClick, - modifier = Modifier.fillMaxWidth(), - ) - } else { - SecondaryButton( - text = stringResource(id = secondaryButtonText), - onClick = onSecondaryButtonClick, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -// region preview - -@Composable -private fun SuccessScreenPreview() { - ResultScreenContent( - resultMessage = AnnotatedString("Swap of 1 000 DAI to 1 131,46 MATIC"), - secondaryButtonText = R.string.swapping_success_view_explorer_button_title, - onSecondaryButtonClick = {}, - onButtonClick = {}, - ) -} - -@Preview(showBackground = true) -@Composable -private fun Preview_SuccessScreenContent_InLightTheme() { - TangemTheme(isDark = false) { - SuccessScreenPreview() - } -} - -@Preview(showBackground = true) -@Composable -private fun Preview_SuccessScreenContent_InDarkTheme() { - TangemTheme(isDark = true) { - SuccessScreenPreview() - } -} - -// endregion preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButton.kt index da053a53f7..afb57565f8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButton.kt @@ -3,14 +3,13 @@ package com.tangem.core.ui.components.appbar import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -49,7 +48,11 @@ fun AppBarWithBackButton( contentDescription = null, modifier = Modifier .size(size = TangemTheme.dimens.size24) - .clickable { onBackClick() }, + .clickable( + indication = rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = onBackClick, + ), tint = TangemTheme.colors.icon.primary1, ) if (!text.isNullOrBlank()) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/BoundCounter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/BoundCounter.kt new file mode 100644 index 0000000000..6a97038b3d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/BoundCounter.kt @@ -0,0 +1,38 @@ +package com.tangem.core.ui.components.atoms.text + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.text.TextLayoutResult + +internal class BoundCounter( + private val text: String, + private val textLayoutResult: TextLayoutResult, + private val charPosition: (Int) -> Int, +) { + var string = "" + private set + var width = 0f + private set + + private var _nextCharWidth: Float? = null + private var invalidCharsCount = 0 + + fun widthWithNextChar(): Float = width + nextCharWidth() + + private fun nextCharWidth(): Float = _nextCharWidth ?: run { + var boundingBox: Rect + // invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630 + invalidCharsCount-- + do { + boundingBox = textLayoutResult + .getBoundingBox(charPosition(string.count() + ++invalidCharsCount)) + } while (boundingBox.right == 0f) + _nextCharWidth = boundingBox.width + boundingBox.width + } + + fun addNextChar() { + string += text[charPosition(string.count())] + width += nextCharWidth() + _nextCharWidth = null + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/EllipsisText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/EllipsisText.kt new file mode 100644 index 0000000000..d95b813ee1 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/EllipsisText.kt @@ -0,0 +1,242 @@ +package com.tangem.core.ui.components.atoms.text + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.TextUnit +import com.tangem.core.ui.res.TangemTheme + +sealed class TextEllipsis { + + object Middle : TextEllipsis() + + object End : TextEllipsis() + + data class OffsetEnd( + val offsetEnd: Int = 0, + val hasSeparator: Boolean = true, + ) : TextEllipsis() +} + +/** + * https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose + * + * Customized Text with ellipsis. Ellipsis can be placed in: Middle, End or OffsetEnd (OffsetEnd with separator). + * + * * OffsetEnd can be useful to display big amounts with currency symbol. OffsetEnd 0 is equal to End. + */ +@Suppress("LongMethod") +@Composable +fun EllipsisText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + softWrap: Boolean = true, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, + ellipsis: TextEllipsis = TextEllipsis.End, +) { + val ellipsisText = remember(text) { + if (ellipsis is TextEllipsis.OffsetEnd && ellipsis.hasSeparator) { + ELLIPSIS_TEXT_WITH_SEPARATOR + } else { + ELLIPSIS_TEXT + } + } + + // some letters, like "r", will have less width when placed right before "." + // adding a space to prevent such case + val layoutText = remember(text) { "$text $ellipsisText" } + val textLayoutResultState = remember(layoutText) { + mutableStateOf(null) + } + SubcomposeLayout(modifier) { constraints -> + // result is ignored - we only need to fill our textLayoutResult + subcompose("measure") { + Text( + text = layoutText, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + softWrap = softWrap, + maxLines = 1, + onTextLayout = { textLayoutResultState.value = it }, + style = style, + ) + }.first().measure(Constraints()) + // to allow smart cast + val textLayoutResult = textLayoutResultState.value + ?: // shouldn't happen - onTextLayout is called before subcompose finishes + return@SubcomposeLayout layout(0, 0) {} + val placeable = subcompose("visible") { + val finalText = remember(text, textLayoutResult, constraints.maxWidth) { + if ( + text.isEmpty() || + textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth + ) { + // text not including ellipsis fits on the first line. + return@remember text + } + + var ellipsisWidth = 0f + layoutText.indices.toList() + .takeLast(ellipsisText.length) + .forEach widthLet@{ + ellipsisWidth += textLayoutResult.getBoundingBox(it).width + } + + val availableWidth = constraints.maxWidth - ellipsisWidth + val startCounter = BoundCounter(text, textLayoutResult) { it } + val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it } + + when (ellipsis) { + TextEllipsis.Middle -> { + middleEllipsisText( + availableWidth, + startCounter, + endCounter, + ) + } + TextEllipsis.End -> { + offsetEndEllipsisText( + availableWidth = availableWidth, + startCounter = startCounter, + endCounter = endCounter, + ) + } + is TextEllipsis.OffsetEnd -> { + offsetEndEllipsisText( + availableWidth = availableWidth, + startCounter = startCounter, + endCounter = endCounter, + offsetEnd = ellipsis.offsetEnd, + withSeparator = ellipsis.hasSeparator, + ) + } + } + } + Text( + text = finalText, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + softWrap = softWrap, + onTextLayout = onTextLayout, + style = style, + ) + }[0].measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + } +} + +private const val ELLIPSIS_SEPARATOR = " " +private const val ELLIPSIS_TEXT = "..." +private const val ELLIPSIS_TEXT_WITH_SEPARATOR = ELLIPSIS_TEXT.plus(ELLIPSIS_SEPARATOR) + +private fun middleEllipsisText(availableWidth: Float, startCounter: BoundCounter, endCounter: BoundCounter): String { + while (availableWidth - startCounter.width - endCounter.width > 0) { + val possibleEndWidth = endCounter.widthWithNextChar() + if ( + startCounter.width >= possibleEndWidth && + availableWidth - startCounter.width - possibleEndWidth >= 0 + ) { + endCounter.addNextChar() + } else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) { + startCounter.addNextChar() + } else { + break + } + } + return startCounter.string.trimEnd() + ELLIPSIS_TEXT + endCounter.string.reversed().trimStart() +} + +private fun offsetEndEllipsisText( + availableWidth: Float, + startCounter: BoundCounter, + endCounter: BoundCounter, + offsetEnd: Int = 0, + withSeparator: Boolean = false, +): String { + while (availableWidth - startCounter.width - endCounter.width > 0) { + val possibleEndWidth = endCounter.widthWithNextChar() + if ( + offsetEnd > endCounter.string.length && + availableWidth - startCounter.width - possibleEndWidth >= 0 + ) { + endCounter.addNextChar() + } else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) { + startCounter.addNextChar() + } else { + break + } + } + val ellipsis = if (withSeparator) ELLIPSIS_TEXT_WITH_SEPARATOR else ELLIPSIS_TEXT + + return startCounter.string.trimEnd() + ellipsis + endCounter.string.reversed().trimStart() +} + +//region Preview +@Preview(widthDp = 200) +@Composable +private fun EllipsisTexPreview(@PreviewParameter(EllipsisTexPreviewParameterProvider::class) ellipsis: TextEllipsis) { + TangemTheme { + EllipsisText( + text = "11111111111111111111111111111111111111111111111111 END", + ellipsis = ellipsis, + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .fillMaxWidth(), + ) + } +} + +private class EllipsisTexPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TextEllipsis.Middle, + TextEllipsis.End, + TextEllipsis.OffsetEnd("TEXT".length), + TextEllipsis.OffsetEnd("TEXT".length, false), + ) +} +//endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt index 76a172ffd9..7627f60b0e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -33,7 +33,7 @@ inline fun TangemBottomSheet( sheetState = sheetState, containerColor = contentColor, shape = TangemTheme.shapes.bottomSheetLarge, - dragHandle = { TangemBottomSheetDraggableHeader() }, + dragHandle = { TangemBottomSheetDraggableHeader(contentColor) }, ) { content(config.content) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowApprox.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowApprox.kt new file mode 100644 index 0000000000..b1c49c1291 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowApprox.kt @@ -0,0 +1,162 @@ +package com.tangem.core.ui.components.inputrow + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +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 +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +/** + * [Input Row Approx](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2207-810&mode=design&t=fM1ZU6zQF6g3CaTv-4) + * + * @param leftIcon left token state + * @param leftTitle left token title + * @param leftSubtitle left token subtitle + * @param rightIcon right token state + * @param rightTitle right token title + * @param rightSubtitle right token subtitle + * @param modifier composable modifier + * @param showDivider show divider + */ +@Suppress("LongParameterList") +@Composable +fun InputRowApprox( + leftIcon: TokenIconState, + leftTitle: TextReference, + leftSubtitle: TextReference, + rightIcon: TokenIconState, + rightTitle: TextReference, + rightSubtitle: TextReference, + modifier: Modifier = Modifier, + leftTitleEllipsisOffset: Int = 0, + rightTitleEllipsisOffset: Int = 0, + showDivider: Boolean = false, +) { + DividerContainer( + showDivider = showDivider, + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(TangemTheme.dimens.spacing12) + .fillMaxWidth(), + ) { + InputRowApproxItem( + iconState = leftIcon, + title = leftTitle, + subtitle = leftSubtitle, + titleEllipsisOffset = leftTitleEllipsisOffset, + modifier = Modifier.weight(1f), + ) + Icon( + painter = painterResource(id = R.drawable.ic_approx_24), + contentDescription = null, + tint = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing4, + vertical = TangemTheme.dimens.spacing10, + ), + ) + InputRowApproxItem( + iconState = rightIcon, + title = rightTitle, + subtitle = rightSubtitle, + titleEllipsisOffset = rightTitleEllipsisOffset, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun InputRowApproxItem( + iconState: TokenIconState, + title: TextReference, + subtitle: TextReference, + modifier: Modifier = Modifier, + titleEllipsisOffset: Int = 0, +) { + Row( + modifier = modifier, + ) { + TokenIcon( + state = iconState, + modifier = Modifier + .size(TangemTheme.dimens.size36), + ) + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing12, + ), + ) { + EllipsisText( + text = title.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ellipsis = TextEllipsis.OffsetEnd(titleEllipsisOffset), + ) + EllipsisText( + text = subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing2), + ) + } + } +} + +//region Preview +@Preview +@Composable +private fun InputRowApproxPreview_Light() { + TangemTheme { + InputRowApprox( + leftIcon = TokenIconState.Loading, + leftTitle = TextReference.Str("Left title USD"), + leftSubtitle = TextReference.Str("Left subtitle USD"), + leftTitleEllipsisOffset = 3, + rightIcon = TokenIconState.Loading, + rightTitle = TextReference.Str("Right title Right title Right title Right title Right title USD"), + rightSubtitle = TextReference.Str("Right subtitle Right subtitle Right subtitle USD"), + rightTitleEllipsisOffset = 3, + modifier = Modifier + .background(TangemTheme.colors.background.action), + ) + } +} + +@Preview +@Composable +private fun InputRowApproxPreview_Dark() { + TangemTheme(isDark = true) { + InputRowApprox( + leftIcon = TokenIconState.Loading, + leftTitle = TextReference.Str("Left title Left title Left title Left title Left title USD"), + leftSubtitle = TextReference.Str("Left subtitle Left subtitle Left subtitle USD"), + leftTitleEllipsisOffset = 3, + rightIcon = TokenIconState.Loading, + rightTitle = TextReference.Str("Right title USD"), + rightSubtitle = TextReference.Str("Right subtitle USD"), + rightTitleEllipsisOffset = 3, + modifier = Modifier + .background(TangemTheme.colors.background.action), + ) + } +} +//endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt new file mode 100644 index 0000000000..620e3346cf --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt @@ -0,0 +1,221 @@ +package com.tangem.core.ui.components.inputrow + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.currency.tokenicon.LoadingIcon +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +/** + * [Input Row Best Rate](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-889&mode=dev) + * + * @param imageUrl image source url + * @param title title + * @param titleExtra title extra + * @param subtitle subtitle + * @param modifier composable modifier + * @param showTag show tag + * @param showDivider show divider + * @param onIconClick icon click + */ +@Composable +fun InputRowBestRate( + imageUrl: String, + title: TextReference, + titleExtra: TextReference, + subtitle: TextReference, + modifier: Modifier = Modifier, + showTag: Boolean = false, + showDivider: Boolean = false, + onIconClick: (() -> Unit)? = null, +) { + DividerContainer( + showDivider = showDivider, + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(TangemTheme.dimens.spacing12), + ) { + InnerIcon(imageUrl = imageUrl) + Column( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12), + ) { + InnerTitle( + title = title, + titleExtra = titleExtra, + showTag = showTag, + ) + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) + } + SpacerWMax() + onIconClick?.let { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing10) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = onIconClick, + ), + ) + } + } + } +} + +@Composable +private fun InnerTitle(title: TextReference, titleExtra: TextReference, showTag: Boolean = false) { + Row { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = titleExtra.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4), + ) + if (showTag) { + Text( + text = stringResource(R.string.express_provider_best_rate), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = RoundedCornerShape(TangemTheme.dimens.radius20), + ) + .padding(horizontal = TangemTheme.dimens.spacing6), + ) + } + } +} + +@Composable +private fun InnerIcon(imageUrl: String) { + SubcomposeAsyncImage( + modifier = Modifier.size(TangemTheme.dimens.size40), + model = ImageRequest.Builder(context = LocalContext.current) + .data(imageUrl) + .crossfade(enable = true) + .allowHardware(enable = false) + .build(), + loading = { LoadingIcon() }, + error = { + Box( + modifier = Modifier + .background( + color = TangemTheme.colors.background.tertiary, + shape = CircleShape, + ), + ) + }, + contentDescription = null, + ) +} + +//region preview +@Preview +@Composable +private fun InputRowBestRatePreview_Light( + @PreviewParameter(InputRowBestRatePreviewDataProvider::class) data: InputRowBestRatePreviewData, +) { + TangemTheme { + InputRowBestRate( + imageUrl = "", + title = data.title, + titleExtra = data.titleExtra, + subtitle = data.subtitle, + showTag = data.showTag, + onIconClick = data.iconClick, + modifier = Modifier + .background(TangemTheme.colors.background.action), + ) + } +} + +@Preview +@Composable +private fun InputRowBestRatePreview_Dark( + @PreviewParameter(InputRowBestRatePreviewDataProvider::class) data: InputRowBestRatePreviewData, +) { + TangemTheme(isDark = true) { + InputRowBestRate( + imageUrl = "", + title = data.title, + titleExtra = data.titleExtra, + subtitle = data.subtitle, + showTag = data.showTag, + onIconClick = data.iconClick, + modifier = Modifier + .background(TangemTheme.colors.background.action), + ) + } +} + +private data class InputRowBestRatePreviewData( + val title: TextReference, + val titleExtra: TextReference, + val showTag: Boolean, + val subtitle: TextReference, + val iconClick: (() -> Unit)?, +) + +private class InputRowBestRatePreviewDataProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + InputRowBestRatePreviewData( + title = TextReference.Str("1inch"), + titleExtra = TextReference.Str("DEX"), + subtitle = TextReference.Str("0,64554846 DAI ≈ 1 MATIC "), + showTag = true, + iconClick = {}, + ), + InputRowBestRatePreviewData( + title = TextReference.Str("ChangeNow"), + titleExtra = TextReference.Str("CEX"), + subtitle = TextReference.Str("0,64554846 DAI ≈ 1 MATIC "), + showTag = false, + iconClick = null, + ), + ) +} +//endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index 2f30c95eba..c352cf41eb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -8,7 +8,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -42,8 +44,18 @@ import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsSta * >Figma component */ @Composable -fun Notification(config: NotificationConfig, modifier: Modifier = Modifier, iconTint: Color? = null) { - BaseContainer(buttonsState = config.buttonsState, onClick = config.onClick, modifier = modifier) { +fun Notification( + config: NotificationConfig, + modifier: Modifier = Modifier, + containerColor: Color? = null, + iconTint: Color? = null, +) { + BaseContainer( + buttonsState = config.buttonsState, + onClick = config.onClick, + modifier = modifier, + containerColor = containerColor, + ) { Column( modifier = Modifier.padding(all = TangemTheme.dimens.spacing12), verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), @@ -71,9 +83,10 @@ private fun BaseContainer( buttonsState: NotificationConfig.ButtonsState?, onClick: (() -> Unit)?, modifier: Modifier = Modifier, + containerColor: Color? = null, content: @Composable BoxScope.() -> Unit, ) { - val containerColor by rememberUpdatedState( + val tempContainerColor by rememberUpdatedState( newValue = if (buttonsState != null || onClick != null) { TangemTheme.colors.background.primary } else { @@ -88,7 +101,7 @@ private fun BaseContainer( .fillMaxWidth(), enabled = onClick != null, shape = TangemTheme.shapes.roundedCornersXMedium, - color = containerColor, + color = containerColor ?: tempContainerColor, ) { Box(content = content) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt new file mode 100644 index 0000000000..0c2a785acc --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt @@ -0,0 +1,85 @@ +package com.tangem.core.ui.components.rows + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH28 +import com.tangem.core.ui.res.TangemTheme + +/** + * Simple clickable action row, without input and icon + * + * https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-807&mode=design&t=Ygv5sohTTHYAQcBS-4 + */ +@Composable +fun SimpleActionRow(title: String, description: String, modifier: Modifier = Modifier, isClickable: Boolean = true) { + Box( + modifier = modifier + .background(color = TangemTheme.colors.background.action) + .height(TangemTheme.dimens.size44) + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .padding(end = TangemTheme.dimens.spacing48) + .align(Alignment.CenterStart), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + AnimatedContent(targetState = title, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) + } + AnimatedContent(targetState = description, label = "") { + Text( + text = it, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + } + + if (isClickable) { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + tint = TangemTheme.colors.icon.informative, + ) + } + } +} + +@Preview +@Composable +private fun SimpleActionRowPreview() { + Column { + TangemTheme(isDark = false) { + SimpleActionRow( + title = "Title", + description = "Description", + ) + } + + SpacerH28() + + TangemTheme(isDark = false) { + SimpleActionRow( + title = "Title", + description = "Description", + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt new file mode 100644 index 0000000000..667ec44c08 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt @@ -0,0 +1,160 @@ +package com.tangem.core.ui.components.rows + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +@Composable +fun SelectorRowItem( + @StringRes titleRes: Int, + @DrawableRes iconRes: Int, + onSelect: () -> Unit, + modifier: Modifier = Modifier, + preEllipsize: TextReference? = null, + postEllipsize: TextReference? = null, + isSelected: Boolean = false, + showDivider: Boolean = true, +) { + val iconTint by animateColorAsState( + targetValue = if (isSelected) { + TangemTheme.colors.icon.accent + } else { + TangemTheme.colors.icon.informative + }, + label = "Selector icon tint change", + ) + + val textStyle = if (isSelected) { + TangemTheme.typography.subtitle2 + } else { + TangemTheme.typography.body2 + } + + Box( + modifier = modifier + .fillMaxWidth() + .clickable { onSelect() }, + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Icon( + painter = painterResource(iconRes), + tint = iconTint, + contentDescription = null, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + ), + ) + Text( + text = stringResource(titleRes), + style = textStyle, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing8, + top = TangemTheme.dimens.spacing14, + bottom = TangemTheme.dimens.spacing14, + ), + ) + if (preEllipsize != null && postEllipsize != null) { + SelectorValueContent( + amount = preEllipsize, + symbol = postEllipsize, + textStyle = textStyle, + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(TangemTheme.dimens.size1) + .padding(horizontal = TangemTheme.dimens.spacing12) + .background(TangemTheme.colors.stroke.primary) + .align(Alignment.BottomCenter), + ) + } + } +} + +@Composable +private fun RowScope.SelectorValueContent(amount: TextReference, symbol: TextReference, textStyle: TextStyle) { + Text( + text = amount.resolveReference(), + style = textStyle, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.End, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier + .weight(1f) + .padding( + start = TangemTheme.dimens.spacing4, + top = TangemTheme.dimens.spacing14, + bottom = TangemTheme.dimens.spacing14, + ), + ) + Text( + text = symbol.resolveReference(), + style = textStyle, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing1, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing14, + bottom = TangemTheme.dimens.spacing14, + ), + ) +} + +@Preview +@Composable +private fun SelectorRowItemPreview_Light() { + TangemTheme { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_slow, + iconRes = R.drawable.ic_tortoise_24, + preEllipsize = TextReference.Str("1000"), + postEllipsize = TextReference.Str("$"), + isSelected = true, + onSelect = { }, + ) + } +} + +@Preview +@Composable +private fun SelectorRowItemPreview_Dark() { + TangemTheme(isDark = true) { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_slow, + iconRes = R.drawable.ic_tortoise_24, + preEllipsize = TextReference.Str("1000"), + postEllipsize = TextReference.Str("$"), + isSelected = true, + onSelect = { }, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt index d44b32ecdf..766815d328 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt @@ -104,6 +104,16 @@ fun combinedReference(refs: WrappedList): TextReference { return TextReference.Combined(refs) } +/** + * Combines multiple [TextReference] instances into a single [TextReference]. + * + * @param refs Vararg of [TextReference] instances to be combined. + * @return A [TextReference] representing the combined text references. + */ +fun combinedReference(vararg refs: TextReference): TextReference { + return TextReference.Combined(WrappedList(listOf(*refs))) +} + /** Resolve [TextReference] as [String] */ @Composable @ReadOnlyComposable diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt index 2fc319885d..e68dfd96a0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt @@ -41,6 +41,7 @@ data class TangemDimens internal constructor( val size0: Dp = 0.dp, val size0_5: Dp = 0.5.dp, val size1: Dp = 1.dp, + val size1_5: Dp = 1.5.dp, val size2: Dp = 2.dp, val size4: Dp = 4.dp, val size5: Dp = 5.dp, 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 93dae822ba..d2c890aad3 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 @@ -157,7 +157,7 @@ private fun darkThemeColors(): TangemColors { ), button = TangemColors.Button( primary = TangemColorPalette.Light4, - secondary = TangemColorPalette.Dark5, + secondary = TangemColorPalette.Dark4, disabled = TangemColorPalette.Dark5, positiveDisabled = TangemColorPalette.DarkGreen, ), diff --git a/core/ui/src/main/res/drawable/ic_alert_triangle_20.xml b/core/ui/src/main/res/drawable/ic_alert_triangle_20.xml new file mode 100644 index 0000000000..dfe1b75e71 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_alert_triangle_20.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_approx_24.xml b/core/ui/src/main/res/drawable/ic_approx_24.xml new file mode 100644 index 0000000000..30eb78ec9d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_approx_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_exclamation_24.xml b/core/ui/src/main/res/drawable/ic_exclamation_24.xml new file mode 100644 index 0000000000..d7d5bdca87 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_exclamation_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_forward_24.xml b/core/ui/src/main/res/drawable/ic_forward_24.xml new file mode 100644 index 0000000000..1a35b84bfb --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_forward_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_no_token_44.xml b/core/ui/src/main/res/drawable/ic_no_token_44.xml new file mode 100644 index 0000000000..49e3b75b2a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_no_token_44.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/utils/src/main/java/com/tangem/utils/CryptoCurrencyFormatExtensions.kt b/core/utils/src/main/java/com/tangem/utils/CryptoCurrencyFormatExtensions.kt index 1e266be497..a1995e1712 100644 --- a/core/utils/src/main/java/com/tangem/utils/CryptoCurrencyFormatExtensions.kt +++ b/core/utils/src/main/java/com/tangem/utils/CryptoCurrencyFormatExtensions.kt @@ -25,7 +25,7 @@ fun BigDecimal.toFormattedString( @Suppress("MagicNumber") fun BigDecimal.toFormattedCurrencyString( decimals: Int, - currency: String, + currency: String? = null, roundingMode: RoundingMode = RoundingMode.DOWN, limitNumberOfDecimals: Boolean = true, ): String { @@ -38,7 +38,8 @@ fun BigDecimal.toFormattedCurrencyString( decimals = decimalsForRounding, roundingMode = roundingMode, ) - return "$formattedAmount $currency" + val formattedCurrency = currency?.let { " $it " } ?: "" + return "$formattedAmount$formattedCurrency" } fun BigDecimal.toFiatString( diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt similarity index 96% rename from features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt rename to core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt index e1343a73cd..5db470b916 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.viewmodels +package com.tangem.utils.coroutines import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index f435378502..db088b565c 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -2,11 +2,12 @@ package com.tangem.data.tokens.di import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.tokens.repository.* +import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.network.NetworksStatusesStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.quote.QuotesStore -import com.tangem.datasource.local.token.UserMarketCoinsStore +import com.tangem.datasource.local.token.AssetsStore import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.tokens.repository.* @@ -26,17 +27,19 @@ internal object TokensDataModule { @Singleton fun provideCurrenciesRepository( tangemTechApi: TangemTechApi, + tangemExpressApi: TangemExpressApi, userTokensStore: UserTokensStore, userWalletsStore: UserWalletsStore, - userMarketCoinsStore: UserMarketCoinsStore, + assetsStore: AssetsStore, cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, ): CurrenciesRepository { return DefaultCurrenciesRepository( tangemTechApi = tangemTechApi, + tangemExpressApi = tangemExpressApi, userTokensStore = userTokensStore, userWalletsStore = userWalletsStore, - userMarketCoinsStore = userMarketCoinsStore, + assetsStore = assetsStore, cacheRegistry = cacheRegistry, dispatchers = dispatchers, ) @@ -82,10 +85,8 @@ internal object TokensDataModule { @Provides @Singleton - fun provideDefaultMarketCoinsRepository( - userMarketCoinsStore: UserMarketCoinsStore, - ): MarketCryptoCurrencyRepository { - return DefaultMarketCryptoCurrencyRepository(userMarketCoinsStore) + fun provideDefaultMarketCoinsRepository(assetsStore: AssetsStore): MarketCryptoCurrencyRepository { + return DefaultMarketCryptoCurrencyRepository(assetsStore) } @Provides diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index ca0202ae25..d927d3e04e 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -5,9 +5,14 @@ import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.tokens.utils.* 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.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE +import com.tangem.datasource.api.express.models.request.AssetsRequestBody +import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.local.token.UserMarketCoinsStore +import com.tangem.datasource.local.token.AssetsStore import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.common.extensions.toCoinId @@ -28,12 +33,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -@Suppress("LargeClass") +@Suppress("LargeClass", "LongParameterList") internal class DefaultCurrenciesRepository( private val tangemTechApi: TangemTechApi, + private val tangemExpressApi: TangemExpressApi, private val userTokensStore: UserTokensStore, private val userWalletsStore: UserWalletsStore, - private val userMarketCoinsStore: UserMarketCoinsStore, + private val assetsStore: AssetsStore, private val cacheRegistry: CacheRegistry, private val dispatchers: CoroutineDispatcherProvider, ) : CurrenciesRepository { @@ -88,12 +94,14 @@ internal class DefaultCurrenciesRepository( val newCurrencies = (newCoins + filteredCurrencies).distinct() + val updatedResponse = savedCurrencies.copy( + tokens = savedCurrencies.tokens + newCurrencies.map(userTokensResponseFactory::createResponseToken), + ) storeAndPushTokens( userWalletId = userWalletId, - response = savedCurrencies.copy( - tokens = savedCurrencies.tokens + newCurrencies.map(userTokensResponseFactory::createResponseToken), - ), + response = updatedResponse, ) + fetchExchangeableUserMarketCoinsByIds(userWalletId, updatedResponse) } } @@ -374,14 +382,25 @@ internal class DefaultCurrenciesRepository( userTokens: UserTokensResponse, ) { try { - val networkIds = userTokens.tokens - .distinctBy { it.networkId } - .joinToString(separator = ",") { it.networkId } - val response = tangemTechApi.getCoins(networkIds = networkIds, exchangeable = true) + val tokensList = userTokens.tokens + .map { + LeastTokenInfo( + contractAddress = it.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE, + network = it.networkId, + ) + } - userMarketCoinsStore.store(userWalletId, response) + if (tokensList.isNotEmpty()) { + val response = tangemExpressApi.getAssets( + AssetsRequestBody( + tokensList = tokensList, + ), + ) + + assetsStore.store(userWalletId, response.getOrThrow()) + } } catch (e: Throwable) { - Timber.e(e, "Unable to fetch user market coins for: ${userWalletId.stringValue}") + Timber.e(e, "Unable to fetch assets for: ${userWalletId.stringValue}") } } 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 2211aa96cd..84188ea775 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 @@ -1,28 +1,21 @@ package com.tangem.data.tokens.repository -import com.tangem.blockchain.common.Blockchain -import com.tangem.datasource.local.token.UserMarketCoinsStore -import com.tangem.domain.common.extensions.toNetworkId +import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE +import com.tangem.datasource.local.token.AssetsStore import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository import com.tangem.domain.wallets.models.UserWalletId class DefaultMarketCryptoCurrencyRepository( - private val userMarketCoinsStore: UserMarketCoinsStore, + private val assetsStore: AssetsStore, ) : MarketCryptoCurrencyRepository { - override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Boolean { - val blockchain = Blockchain.fromId(cryptoCurrencyId.rawNetworkId) - val apiNetworkId = blockchain.toNetworkId() - return userMarketCoinsStore.getSyncOrNull(userWalletId)?.coins - ?.firstOrNull { it.id == cryptoCurrencyId.rawCurrencyId } - ?.networks - ?.firstOrNull { - if (it.contractAddress != null) { - it.networkId == apiNetworkId && it.contractAddress == cryptoCurrencyId.contractAddress - } else { - it.networkId == apiNetworkId - } - }?.exchangeable ?: false + override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { + val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE + + return assetsStore.getSyncOrNull(userWalletId)?.find { + it.network == cryptoCurrency.network.backendId && + it.contractAddress.equals(contractAddress, ignoreCase = true) + }?.exchangeAvailable ?: false } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt index cdd04c62ce..7aa81fc78c 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt @@ -67,6 +67,14 @@ internal class DefaultQuotesRepository( } } + override suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote { + return withContext(dispatchers.io) { + val quote = quotesStore.getSync(setOf(currencyId)).firstOrNull() + requireNotNull(quote) { "Unable to get quote for $currencyId" } + quotesConverter.convert(quote) + } + } + private suspend fun fetchExpiredQuotes( currenciesIds: Set, appCurrencyId: String, diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt index 21ef628101..8b2a7175c6 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt @@ -2,6 +2,7 @@ package com.tangem.data.tokens.utils import com.tangem.blockchain.common.Blockchain import com.tangem.domain.common.DerivationStyleProvider +import com.tangem.domain.common.extensions.toNetworkId import com.tangem.domain.tokens.model.Network import timber.log.Timber @@ -21,9 +22,11 @@ internal fun getNetwork( return Network( id = Network.ID(blockchain.id), + backendId = blockchain.toNetworkId(), name = blockchain.fullName, isTestnet = blockchain.isTestnet(), derivationPath = getNetworkDerivationPath(blockchain, extraDerivationPath, derivationStyleProvider), + currencySymbol = blockchain.currency, standardType = getNetworkStandardType(blockchain), ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt index 13dcd79a67..54f468860b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt @@ -4,7 +4,6 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.IconsUtil import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.domain.common.extensions.toCoinId -import com.tangem.domain.common.extensions.toNetworkId import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency.ID import com.tangem.domain.tokens.model.Network @@ -55,8 +54,7 @@ internal fun getTokenIconUrl(blockchain: Blockchain, token: SdkToken): String? { internal fun getCoinIconUrl(blockchain: Blockchain): String? { val coinId = when (blockchain) { Blockchain.Unknown -> null - Blockchain.TerraV1, Blockchain.TerraV2, Blockchain.Near -> blockchain.toCoinId() - else -> blockchain.toNetworkId() + else -> blockchain.toCoinId() } return coinId?.let(::getTokenIconUrlFromDefaultHost) diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt index 2bc545ba77..17274cfbe9 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt @@ -11,6 +11,7 @@ import kotlinx.parcelize.Parcelize * (e.g., ERC20, BEP20). * * @property id The unique identifier of the network. + * @property backendId The name of this network in the Tangem backend. * @property name The human-readable name of the network, such as "Ethereum" or "Bitcoin". * @property derivationPath The path used to derive keys for this network. * @property isTestnet Indicates whether the network is a test network or a main network. @@ -19,7 +20,9 @@ import kotlinx.parcelize.Parcelize @Parcelize data class Network( val id: ID, + val backendId: String, val name: String, + val currencySymbol: String, val derivationPath: DerivationPath, val isTestnet: Boolean, val standardType: StandardType, diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt new file mode 100644 index 0000000000..a2161adc46 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt @@ -0,0 +1,34 @@ +package com.tangem.domain.tokens.models.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +class TokenExchangeAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Token", event, params, null) { + + class CexTxStatusOpened(token: String) : TokenScreenAnalyticsEvent( + event = "ChangeNow Status Opened", + params = mapOf("Token" to token), + ) + + class CexTxStatusChanged(token: String, status: String) : TokenScreenAnalyticsEvent( + event = "ChangeNow Status", + params = mapOf("Token" to token, "Status" to status), + ) + + class GoToProviderStatus(token: String) : TokenScreenAnalyticsEvent( + event = "Button - Go To Provider", + params = mapOf("Token" to token, "Place" to "Status"), + ) + + class GoToProviderKYC(token: String) : TokenScreenAnalyticsEvent( + event = "Button - Go To Provider", + params = mapOf("Token" to token, "Place" to "KYC"), + ) + + class GoToProviderFail(token: String) : TokenScreenAnalyticsEvent( + event = "Button - Go To Provider", + params = mapOf("Token" to token, "Place" to "Fail"), + ) +} \ 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 ff04eae099..87dee2d3d9 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,5 +1,6 @@ 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 @@ -55,6 +56,7 @@ class GetCryptoCurrencyActionsUseCase( userWalletId = userWallet.walletId, coinStatus = maybeCoinStatus.getOrNull(), cryptoCurrencyStatus = cryptoCurrencyStatus, + cardTypesResolver = userWallet.scanResponse.cardTypesResolver, ) } @@ -66,11 +68,17 @@ class GetCryptoCurrencyActionsUseCase( userWalletId: UserWalletId, coinStatus: CryptoCurrencyStatus?, cryptoCurrencyStatus: CryptoCurrencyStatus, + cardTypesResolver: CardTypesResolver, ): TokenActionsState { return TokenActionsState( walletId = userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, - states = createListOfActions(userWalletId, coinStatus, cryptoCurrencyStatus), + states = createListOfActions( + userWalletId, + coinStatus, + cryptoCurrencyStatus, + cardTypesResolver, + ), ) } @@ -82,6 +90,7 @@ class GetCryptoCurrencyActionsUseCase( userWalletId: UserWalletId, coinStatus: CryptoCurrencyStatus?, cryptoCurrencyStatus: CryptoCurrencyStatus, + cardTypesResolver: CardTypesResolver, ): List { val cryptoCurrency = cryptoCurrencyStatus.currency if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.MissedDerivation) { @@ -107,8 +116,11 @@ class GetCryptoCurrencyActionsUseCase( activeList.add(TokenActionsState.ActionState.Send(true)) } + val isMulticurrencyWallet = cardTypesResolver.isTangemWallet() || cardTypesResolver.isWallet2() // swap - if (marketCryptoCurrencyRepository.isExchangeable(userWalletId, cryptoCurrency.id)) { + if (isMulticurrencyWallet && + marketCryptoCurrencyRepository.isExchangeable(userWalletId, cryptoCurrency) + ) { activeList.add(TokenActionsState.ActionState.Swap(true)) } else { disabledList.add(TokenActionsState.ActionState.Swap(false)) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt new file mode 100644 index 0000000000..ffe6e79c3b --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.tokens + +import arrow.core.Either +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.error.mapper.mapToTokenListError +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +class GetCryptoCurrencyStatusSyncUseCase( + internal val currenciesRepository: CurrenciesRepository, + internal val quotesRepository: QuotesRepository, + internal val networksRepository: NetworksRepository, + internal val dispatchers: CoroutineDispatcherProvider, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): Either { + val operations = CurrenciesStatusesOperations( + userWalletId = userWalletId, + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + ) + + return operations.getCurrencyStatusSync(cryptoCurrencyId) + .mapLeft { error -> error.mapToTokenListError() } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusesSyncUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusesSyncUseCase.kt new file mode 100644 index 0000000000..cdf58b3080 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusesSyncUseCase.kt @@ -0,0 +1,32 @@ +package com.tangem.domain.tokens + +import arrow.core.Either +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.error.mapper.mapToTokenListError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +class GetCryptoCurrencyStatusesSyncUseCase( + internal val currenciesRepository: CurrenciesRepository, + internal val quotesRepository: QuotesRepository, + internal val networksRepository: NetworksRepository, + internal val dispatchers: CoroutineDispatcherProvider, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either> { + val operations = CurrenciesStatusesOperations( + userWalletId = userWalletId, + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + ) + + return operations.getCurrenciesStatusesSync() + .mapLeft { error -> error.mapToTokenListError() } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt index 9bce6f0d35..a86698763b 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt @@ -67,6 +67,46 @@ internal class CurrenciesStatusesOperations( } } + suspend fun getCurrenciesStatusesSync(): Either> { + return either { + catch( + block = { + val nonEmptyCurrencies = + currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId).toNonEmptyListOrNull() + ?: return emptyList().right() + val (networks, currenciesIds) = getIds(nonEmptyCurrencies) + val quotes = quotesRepository.getQuotesSync(currenciesIds, false).right() + val networkStatuses = + networksRepository.getNetworkStatusesSync(userWalletId, networks, false).right() + return createCurrenciesStatuses(nonEmptyCurrencies, quotes, networkStatuses) + }, + catch = { raise(Error.DataError(it)) }, + ) + } + } + + suspend fun getCurrencyStatusSync(cryptoCurrencyId: CryptoCurrency.ID): Either { + return either { + catch( + block = { + val currency = + currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId, cryptoCurrencyId) + val quotes = quotesRepository.getQuoteSync(cryptoCurrencyId).right() + val networkStatuses = + networksRepository.getNetworkStatusesSync( + userWalletId, + setOf(currency.network), + false, + ).firstOrNull { + it.network == currency.network + }.right() + return createCurrencyStatus(currency, quotes, networkStatuses) + }, + catch = { raise(Error.DataError(it)) }, + ) + } + } + fun getCardCurrenciesStatusesFlow(): Flow>> { return flow { val nonEmptyCurrencies = recover( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/MarketCryptoCurrencyRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/MarketCryptoCurrencyRepository.kt index 0186226918..0e0d223a53 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/MarketCryptoCurrencyRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/MarketCryptoCurrencyRepository.kt @@ -8,5 +8,5 @@ import com.tangem.domain.wallets.models.UserWalletId */ interface MarketCryptoCurrencyRepository { - suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Boolean + suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt index a44b1e6c99..4c38eda580 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt @@ -29,4 +29,6 @@ interface QuotesRepository { * @return A [Flow] emitting a set of quotes corresponding to the specified cryptocurrencies. */ suspend fun getQuotesSync(currenciesIds: Set, refresh: Boolean): Set + + suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt index 369bb8b5b3..522878e215 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt @@ -18,6 +18,8 @@ internal object MockNetworks { name = "Network One", isTestnet = false, standardType = Network.StandardType.ERC20, + backendId = "network1", + currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, ) @@ -26,6 +28,8 @@ internal object MockNetworks { name = "Network Two", isTestnet = false, standardType = Network.StandardType.ERC20, + backendId = "network1", + currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, ) @@ -34,6 +38,8 @@ internal object MockNetworks { name = "Network Three", isTestnet = false, standardType = Network.StandardType.ERC20, + backendId = "network1", + currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, ) diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt index 6d1c1e1a27..ae960f191a 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt @@ -20,4 +20,9 @@ internal class MockQuotesRepository( override suspend fun getQuotesSync(currenciesIds: Set, refresh: Boolean): Set { return getQuotesUpdates(currenciesIds).first() } + + override suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote { + return quotes.map { it.getOrElse { e -> throw e } }.first() + .first { it.rawCurrencyId == currencyId.rawCurrencyId } + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt index 26fdceff5a..8ed9705902 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt @@ -7,4 +7,13 @@ sealed class SendTransactionError { data class DataError(val message: String?) : SendTransactionError() data class NetworkError(val message: String?) : SendTransactionError() + + data class BlockchainSdkError(val code: Int, val cause: Throwable?) : SendTransactionError() + object UserCancelledError : SendTransactionError() + data class TangemSdkError(val code: Int, val cause: Throwable?) : SendTransactionError() + data class UnknownError(val ex: Exception? = null) : SendTransactionError() + + companion object { + const val USER_CANCELLED_ERROR_CODE = 50002 + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt index 0a289bfcdc..6860e413c9 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt @@ -3,13 +3,17 @@ package com.tangem.domain.transaction.usecase import arrow.core.Either import arrow.core.left import arrow.core.right +import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.blockchain.network.ResultChecker +import com.tangem.common.core.TangemSdkError import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.TapWorkarounds.isStart2Coin import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_CANCELLED_ERROR_CODE import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWallet @@ -52,10 +56,40 @@ class SendTransactionUseCase( ifRight = { result -> when (result) { is SimpleResult.Success -> true.right() - is SimpleResult.Failure -> SendTransactionError.NetworkError(result.error.message).left() + is SimpleResult.Failure -> handleError(result).left() } }, ifLeft = { it.left() }, ) } + + private fun handleError(result: SimpleResult.Failure): SendTransactionError { + if (ResultChecker.isNetworkError(result)) return SendTransactionError.NetworkError(result.error.message) + val error = result.error as? BlockchainSdkError ?: return SendTransactionError.UnknownError() + when (error) { + is BlockchainSdkError.WrappedTangemError -> { + val errorByCode = mapErrorByCode(error) + if (errorByCode != null) { + return errorByCode + } + val tangemSdkError = error.tangemError as? TangemSdkError ?: return SendTransactionError.UnknownError() + if (tangemSdkError is TangemSdkError.UserCancelled) return SendTransactionError.UserCancelledError + return SendTransactionError.TangemSdkError(tangemSdkError.code, tangemSdkError.cause) + } + else -> { + return SendTransactionError.TangemSdkError(error.code, error.cause) + } + } + } + + private fun mapErrorByCode(error: BlockchainSdkError.WrappedTangemError): SendTransactionError? { + return when (error.code) { + USER_CANCELLED_ERROR_CODE -> { + return SendTransactionError.UserCancelledError + } + else -> { + null + } + } + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt index 4b027a624d..9ab26bd83d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt @@ -24,7 +24,7 @@ internal class SendAmountFieldConverter( ), placeholder = TextReference.Str(DEFAULT_VALUE), isError = false, - error = TextReference.Res(R.string.send_insufficient_funds), + error = TextReference.Res(R.string.swapping_insufficient_funds), ) } diff --git a/features/swap/data/build.gradle.kts b/features/swap/data/build.gradle.kts index 67d016525d..53dddfc5a9 100644 --- a/features/swap/data/build.gradle.kts +++ b/features/swap/data/build.gradle.kts @@ -8,20 +8,25 @@ plugins { dependencies { + /** AndroidX */ + implementation(deps.androidx.datastore) + /** Project*/ - implementation(project(":core:datasource")) - implementation(project(":core:utils")) - implementation(project(":features:swap:domain")) + implementation(projects.core.datasource) + implementation(projects.core.utils) + implementation(projects.features.swap.domain) /** Network */ implementation(deps.retrofit) implementation(deps.moshi) implementation(deps.moshi.kotlin) + implementation(deps.arrow.core) /** Domain */ implementation(projects.domain.tokens.models) implementation(projects.domain.legacy) implementation(projects.domain.models) + implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) /** Data */ @@ -32,5 +37,6 @@ dependencies { /** DI */ implementation(deps.hilt.android) + kapt(deps.hilt.kapt) } \ No newline at end of file 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 new file mode 100644 index 0000000000..97b9a1e2b2 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -0,0 +1,197 @@ +package com.tangem.feature.swap + +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.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.SavedLastSwappedCryptoCurrency +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel +import com.tangem.utils.extensions.addOrReplace +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class DefaultSwapTransactionRepository( + private val appPreferencesStore: AppPreferencesStore, +) : SwapTransactionRepository { + + override suspend fun storeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + transaction: SavedSwapTransactionModel, + ) { + appPreferencesStore.editData { mutablePreferences -> + val savedTransactions: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ) + + val tokenTransactions = savedTransactions + ?.firstOrNull { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrencyId, + toCurrencyId = toCryptoCurrencyId, + ) + } + ?.transactions + ?.addOrReplace( + item = transaction, + predicate = { it.txId == transaction.txId }, + ) ?: listOf(transaction) + + mutablePreferences.setObject( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + value = savedTransactions?.updateList( + userWalletId = userWalletId, + fromCryptoCurrencyId = fromCryptoCurrencyId, + toCryptoCurrencyId = toCryptoCurrencyId, + transactions = tokenTransactions, + ) ?: listOf( + SavedSwapTransactionListModel( + userWalletId = userWalletId.stringValue, + fromCryptoCurrencyId = fromCryptoCurrencyId.value, + toCryptoCurrencyId = toCryptoCurrencyId.value, + transactions = tokenTransactions, + ), + ), + ) + } + } + + override fun getTransactions( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): Flow?> { + return appPreferencesStore.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ).map { savedTransactions -> + savedTransactions + ?.filter { + it.userWalletId == userWalletId.stringValue && + ( + it.toCryptoCurrencyId == cryptoCurrencyId.value || + it.fromCryptoCurrencyId == cryptoCurrencyId.value + ) + } + } + } + + override suspend fun removeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + txId: String, + ) { + appPreferencesStore.editData { mutablePreferences -> + val savedList: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ) + val tokenTransactions = savedList + ?.first { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrencyId, + toCurrencyId = toCryptoCurrencyId, + ) + } + ?.transactions + ?.filterNot { it.txId == txId } + + val editedList = + if (tokenTransactions.isNullOrEmpty()) { + savedList?.filterNot { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrencyId, + toCurrencyId = toCryptoCurrencyId, + ) + } + } else { + savedList.updateList( + userWalletId = userWalletId, + fromCryptoCurrencyId = fromCryptoCurrencyId, + toCryptoCurrencyId = toCryptoCurrencyId, + transactions = tokenTransactions, + ) + } + + if (editedList.isNullOrEmpty()) { + mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_KEY) + } else { + mutablePreferences.setObject( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + value = editedList, + ) + } + } + } + + override suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? { + val lastSwappedCurrencies = appPreferencesStore.getObjectListSync( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + ) + + return lastSwappedCurrencies.find { userWalletId.stringValue == it.userWalletId }?.cryptoCurrencyId + } + + override suspend fun storeLastSwappedCryptoCurrencyId( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ) { + appPreferencesStore.editData { mutablePreferences -> + val lastSwappedCryptoCurrencies: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + ) + + val newList = if (lastSwappedCryptoCurrencies != null) { + lastSwappedCryptoCurrencies.filter { + it.userWalletId != userWalletId.stringValue + } + SavedLastSwappedCryptoCurrency(userWalletId.stringValue, cryptoCurrencyId.value) + } else { + listOf(SavedLastSwappedCryptoCurrency(userWalletId.stringValue, cryptoCurrencyId.value)) + } + + mutablePreferences.setObjectList( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + value = newList, + ) + } + } + + private fun SavedSwapTransactionListModel.checkId( + checkUserWalletId: UserWalletId, + fromCurrencyId: CryptoCurrency.ID, + toCurrencyId: CryptoCurrency.ID, + ): Boolean { + return userWalletId == checkUserWalletId.stringValue && + toCryptoCurrencyId == toCurrencyId.value && + fromCryptoCurrencyId == fromCurrencyId.value + } + + private fun List.updateList( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + transactions: List, + ): List { + return addOrReplace( + item = SavedSwapTransactionListModel( + userWalletId = userWalletId.stringValue, + fromCryptoCurrencyId = fromCryptoCurrencyId.value, + toCryptoCurrencyId = toCryptoCurrencyId.value, + transactions = transactions, + ), + predicate = { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrencyId, + toCurrencyId = toCryptoCurrencyId, + ) + }, + ) + } +} \ No newline at end of file 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/SwapRepositoryImpl.kt index d9ee8d861a..90358d75df 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/SwapRepositoryImpl.kt @@ -1,51 +1,130 @@ package com.tangem.feature.swap +import arrow.core.Either +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 com.tangem.blockchain.extensions.Result import com.tangem.data.tokens.utils.CryptoCurrencyFactory +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.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.oneinch.OneInchErrorsHandler -import com.tangem.datasource.api.oneinch.errors.OneIncResponseException import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.config.ConfigManager 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.models.UserWallet +import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.feature.swap.converters.QuotesConverter -import com.tangem.feature.swap.converters.SwapConverter -import com.tangem.feature.swap.converters.TokensConverter +import com.tangem.feature.swap.converters.* import com.tangem.feature.swap.domain.SwapRepository +import com.tangem.feature.swap.domain.models.DataError +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.Currency -import com.tangem.feature.swap.domain.models.domain.QuoteModel -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.mapErrors +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async import kotlinx.coroutines.withContext import java.math.BigDecimal import javax.inject.Inject -import com.tangem.blockchain.common.Token as SdkToken +import com.tangem.datasource.api.express.models.request.LeastTokenInfo as NetworkLeastTokenInfo +@Suppress("LongParameterList") internal class SwapRepositoryImpl @Inject constructor( private val tangemTechApi: TangemTechApi, + private val tangemExpressApi: TangemExpressApi, private val oneInchApiFactory: OneInchApiFactory, - private val oneInchErrorsHandler: OneInchErrorsHandler, private val coroutineDispatcher: CoroutineDispatcherProvider, private val configManager: ConfigManager, private val walletManagersFacade: WalletManagersFacade, + private val walletsStateHolder: WalletsStateHolder, + private val errorsDataConverter: ErrorsDataConverter, ) : SwapRepository { private val tokensConverter = TokensConverter() - private val quotesConverter = QuotesConverter() - private val swapConverter = SwapConverter() + private val expressDataConverter = ExpressDataConverter() + private val leastTokenInfoConverter = LeastTokenInfoConverter() + private val swapPairInfoConverter = SwapPairInfoConverter() + private val cryptoCurrencyFactory = CryptoCurrencyFactory() + private val exchangeStatusConverter = ExchangeStatusConverter() + + override suspend fun getPairs( + initialCurrency: LeastTokenInfo, + currencyList: List, + ): List { + 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, + ) + } + + val reversedPairs = async { + getPairsInternal( + from = currenciesList, + to = arrayListOf(initial), + ) + } + + val allPairs = pairs.await() + reversedPairs.await() + + val providers = tangemExpressApi.getProviders().getOrThrow() + + return@withContext swapPairInfoConverter.convert( + SwapPairsWithProviders( + swapPair = allPairs, + providers = providers, + ), + ) + } + } + + private suspend fun getPairsInternal( + from: List, + to: List, + ): List { + return tangemExpressApi.getPairs( + PairsRequestBody( + from = from, + to = to, + ), + ).getOrThrow() + } + + override suspend fun getExchangeStatus(txId: String): Either { + return withContext(coroutineDispatcher.io) { + either { + catch( + { + exchangeStatusConverter.convert( + tangemExpressApi + .getExchangeStatus(txId) + .getOrThrow(), + ) + }, + { + raise(UnknownError(it.message)) + }, + ) + } + } + } override suspend fun getRates(currencyId: String, tokenIds: List): Map { // workaround cause backend do not return arbitrum and optimism rates @@ -83,23 +162,37 @@ internal class SwapRepositoryImpl @Inject constructor( } override suspend fun findBestQuote( - networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - amount: String, + fromContractAddress: String, + fromNetwork: String, + toContractAddress: String, + toNetwork: String, + fromAmount: String, + fromDecimals: Int, + toDecimals: Int, + providerId: String, + rateType: RateType, ): AggregatedSwapDataModel { return withContext(coroutineDispatcher.io) { try { - val response = oneInchErrorsHandler.handleOneInchResponse( - getOneInchApi(networkId).quote( - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, - amount = amount, + val response = tangemExpressApi.getExchangeQuote( + fromContractAddress = fromContractAddress, + fromNetwork = fromNetwork, + toContractAddress = toContractAddress, + toNetwork = toNetwork, + fromAmount = fromAmount, + fromDecimals = fromDecimals, + toDecimals = toDecimals, + providerId = providerId, + rateType = rateType.name.lowercase(), + ).getOrThrow() + AggregatedSwapDataModel( + dataModel = QuoteModel( + toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals), + allowanceContract = response.allowanceContract, ), ) - AggregatedSwapDataModel(dataModel = quotesConverter.convert(response)) - } catch (ex: OneIncResponseException) { - AggregatedSwapDataModel(null, mapErrors(ex.data.description)) + } catch (ex: Exception) { + AggregatedSwapDataModel(null, getDataError(ex)) } } } @@ -110,31 +203,37 @@ internal class SwapRepositoryImpl @Inject constructor( } } - override suspend fun prepareSwapTransaction( - networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - amount: String, - fromWalletAddress: String, - slippage: Int, + override suspend fun getExchangeData( + fromContractAddress: String, + fromNetwork: String, + toContractAddress: String, + toNetwork: String, + fromAmount: String, + fromDecimals: Int, + toDecimals: Int, + providerId: String, + rateType: RateType, + toAddress: String, ): AggregatedSwapDataModel { return withContext(coroutineDispatcher.io) { try { - val swapResponse = oneInchErrorsHandler.handleOneInchResponse( - getOneInchApi(networkId).swap( - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, - amount = amount, - fromAddress = fromWalletAddress, - slippage = slippage, - referrerAddress = configManager.config.swapReferrerAccount?.address, - fee = configManager.config.swapReferrerAccount?.fee, - ), + val response = tangemExpressApi.getExchangeData( + fromContractAddress = fromContractAddress, + fromNetwork = fromNetwork, + toContractAddress = toContractAddress, + toNetwork = toNetwork, + fromAmount = fromAmount, + fromDecimals = fromDecimals, + toDecimals = toDecimals, + providerId = providerId, + rateType = rateType.name.lowercase(), + toAddress = toAddress, + ).getOrThrow() + AggregatedSwapDataModel( + dataModel = expressDataConverter.convert(response), ) - - AggregatedSwapDataModel(swapConverter.convert(swapResponse)) - } catch (ex: OneIncResponseException) { - AggregatedSwapDataModel(null, mapErrors(ex.data.description)) + } catch (ex: Exception) { + AggregatedSwapDataModel(null, getDataError(ex)) } } } @@ -143,45 +242,13 @@ internal class SwapRepositoryImpl @Inject constructor( return configManager.config.swapReferrerAccount?.fee?.toDoubleOrNull() ?: 0.0 } - override suspend fun getCryptoCurrency( - userWallet: UserWallet, - currency: Currency, - network: Network, - ): CryptoCurrency? { - val blockchain = Blockchain.fromNetworkId(currency.networkId) ?: return null - val cryptoCurrencyFactory = CryptoCurrencyFactory() - return when (currency) { - is Currency.NativeToken -> { - cryptoCurrencyFactory.createCoin( - blockchain = blockchain, - extraDerivationPath = network.derivationPath.value, - derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, - ) - } - is Currency.NonNativeToken -> { - val sdkToken = SdkToken( - name = currency.name, - symbol = currency.symbol, - contractAddress = currency.contractAddress, - decimals = currency.decimalCount, - id = currency.id, - ) - cryptoCurrencyFactory.createToken( - sdkToken = sdkToken, - blockchain = blockchain, - extraDerivationPath = network.derivationPath.value, - derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, - ) - } - } as CryptoCurrency - } - override suspend fun getAllowance( userWalletId: UserWalletId, networkId: String, derivationPath: String?, tokenDecimalCount: Int, tokenAddress: String, + spenderAddress: String, ): BigDecimal { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } val walletManager = walletManagersFacade.getOrCreateWalletManager( @@ -189,7 +256,6 @@ internal class SwapRepositoryImpl @Inject constructor( blockchain = blockchain, derivationPath = derivationPath, ) - val spenderAddress = addressForTrust(networkId) val result = (walletManager as? Approver)?.getAllowance( spenderAddress, @@ -210,8 +276,9 @@ internal class SwapRepositoryImpl @Inject constructor( userWalletId: UserWalletId, networkId: String, derivationPath: String?, - currency: Currency, + currency: CryptoCurrency, amount: BigDecimal?, + spenderAddress: String, ): String { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } @@ -220,7 +287,6 @@ internal class SwapRepositoryImpl @Inject constructor( blockchain = blockchain, derivationPath = derivationPath, ) - val spenderAddress = addressForTrust(networkId) return (walletManager as? Approver)?.getApproveData( spenderAddress, @@ -228,16 +294,16 @@ internal class SwapRepositoryImpl @Inject constructor( ) ?: error("Cannot cast to Approver") } - private fun convertToAmount(amount: BigDecimal, currency: Currency, blockchain: Blockchain): Amount { + private fun convertToAmount(amount: BigDecimal, currency: CryptoCurrency, blockchain: Blockchain): Amount { return when (currency) { - is Currency.NativeToken -> { + is CryptoCurrency.Token -> { Amount(value = amount, blockchain = blockchain) } - is Currency.NonNativeToken -> { + is CryptoCurrency.Coin -> { Amount( currencySymbol = currency.symbol, value = amount, - decimals = currency.decimalCount, + decimals = currency.decimals, ) } } @@ -247,6 +313,31 @@ internal class SwapRepositoryImpl @Inject constructor( return oneInchApiFactory.getApi(networkId) } + override fun getNativeTokenForNetwork(networkId: String): CryptoCurrency { + val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } + + return requireNotNull( + cryptoCurrencyFactory.createCoin( + blockchain = blockchain, + extraDerivationPath = null, + derivationStyleProvider = requireNotNull( + walletsStateHolder.userWalletsListManager + ?.selectedUserWalletSync + ?.scanResponse + ?.derivationStyleProvider, + ), + ), + ) + } + + private fun getDataError(ex: Exception): DataError { + return if (ex is ApiResponseError.HttpException) { + errorsDataConverter.convert(ex.errorBody ?: "") + } else { + DataError.UnknownError + } + } + companion object { // TODO("get this ids from blockchain enum later") private const val OPTIMISM_ID = "optimistic-ethereum" 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 new file mode 100644 index 0000000000..2068fc982a --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt @@ -0,0 +1,48 @@ +package com.tangem.feature.swap.converters + +import com.squareup.moshi.JsonAdapter +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 +import com.tangem.utils.converter.Converter + +internal class ErrorsDataConverter( + private val jsonAdapter: JsonAdapter, +) : Converter { + + @Suppress("MagicNumber") + override fun convert(value: String): DataError { + try { + val error = jsonAdapter.fromJson(value)?.error ?: return DataError.UnknownError + + return when (error.code) { + 2010 -> DataError.BadRequest(code = error.code) + 2210 -> DataError.ExchangeProviderNotFoundError(code = error.code) + 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), + ) + 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 + } + } catch (e: Exception) { + return DataError.UnknownError + } + } +} \ 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 new file mode 100644 index 0000000000..f5901e8c69 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExchangeStatusConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.feature.swap.converters + +import com.tangem.datasource.api.express.models.response.ExchangeStatusResponse +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel +import com.tangem.utils.converter.Converter + +internal class ExchangeStatusConverter : Converter { + override fun convert(value: ExchangeStatusResponse): ExchangeStatusModel { + return ExchangeStatusModel( + providerId = value.providerId, + status = ExchangeStatus.values().firstOrNull { + it.name.lowercase() == value.externalStatus.name.lowercase() + }, + txId = value.externalTxId, + txUrl = value.externalTxUrl, + ) + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt new file mode 100644 index 0000000000..6746243002 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt @@ -0,0 +1,40 @@ +package com.tangem.feature.swap.converters + +import com.tangem.datasource.api.express.models.response.ExchangeDataResponse +import com.tangem.datasource.api.express.models.response.TxType +import com.tangem.feature.swap.domain.models.createFromAmountWithOffset +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.utils.converter.Converter + +class ExpressDataConverter : Converter { + + override fun convert(value: ExchangeDataResponse): SwapDataModel { + return SwapDataModel( + toTokenAmount = createFromAmountWithOffset(value.toAmount, value.toDecimals), + transaction = convertTransaction(value), + ) + } + + private fun convertTransaction(transactionDto: 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, + 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, + txTo = transactionDto.txTo, + externalTxId = requireNotNull(transactionDto.externalTxId), + externalTxUrl = requireNotNull(transactionDto.externalTxUrl), + ) + } + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt new file mode 100644 index 0000000000..4ee7532cc7 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt @@ -0,0 +1,17 @@ +package com.tangem.feature.swap.converters + +import com.tangem.blockchain.common.Blockchain +import com.tangem.datasource.api.express.models.request.LeastTokenInfo +import com.tangem.domain.common.extensions.toNetworkId +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.converter.Converter + +class LeastTokenInfoConverter : Converter { + + override fun convert(value: CryptoCurrency): LeastTokenInfo { + return LeastTokenInfo( + contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = Blockchain.fromId(value.id.rawNetworkId).toNetworkId(), + ) + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/QuotesConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/QuotesConverter.kt deleted file mode 100644 index 4352341538..0000000000 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/QuotesConverter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.feature.swap.converters - -import com.tangem.datasource.api.oneinch.models.QuoteResponse -import com.tangem.feature.swap.domain.models.createFromAmountWithOffset -import com.tangem.feature.swap.domain.models.domain.QuoteModel -import com.tangem.utils.converter.Converter - -class QuotesConverter : Converter { - - override fun convert(value: QuoteResponse): QuoteModel { - return QuoteModel( - toTokenAmount = createFromAmountWithOffset(value.toTokenAmount, value.toToken.decimals), - ) - } -} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/RateTypeConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/RateTypeConverter.kt new file mode 100644 index 0000000000..0d25c4eca2 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/RateTypeConverter.kt @@ -0,0 +1,22 @@ +package com.tangem.feature.swap.converters + +import com.tangem.datasource.api.express.models.response.RateType +import com.tangem.utils.converter.TwoWayConverter +import com.tangem.feature.swap.domain.models.domain.RateType as RateTypeDomain + +class RateTypeConverter : TwoWayConverter { + + override fun convert(value: RateType): RateTypeDomain { + return when (value) { + RateType.FIXED -> RateTypeDomain.FIXED + RateType.FLOAT -> RateTypeDomain.FLOAT + } + } + + override fun convertBack(value: RateTypeDomain): RateType { + return when (value) { + RateTypeDomain.FIXED -> RateType.FIXED + RateTypeDomain.FLOAT -> RateType.FLOAT + } + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapConverter.kt deleted file mode 100644 index 3c8c1003ec..0000000000 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapConverter.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.tangem.feature.swap.converters - -import com.tangem.datasource.api.oneinch.models.SwapResponse -import com.tangem.datasource.api.oneinch.models.TransactionDto -import com.tangem.feature.swap.domain.models.createFromAmountWithOffset -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.domain.TransactionModel -import com.tangem.utils.converter.Converter - -class SwapConverter : Converter { - - override fun convert(value: SwapResponse): SwapDataModel { - return SwapDataModel( - toTokenAmount = createFromAmountWithOffset(value.toTokenAmount, value.toToken.decimals), - transaction = convertTransaction(value.transaction), - ) - } - - private fun convertTransaction(transactionDto: TransactionDto): TransactionModel { - return TransactionModel( - fromWalletAddress = transactionDto.fromAddress, - toWalletAddress = transactionDto.toAddress, - data = transactionDto.data, - value = transactionDto.value, - gasPrice = transactionDto.gasPrice, - gas = transactionDto.gas, - ) - } -} \ No newline at end of file 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 new file mode 100644 index 0000000000..ff86a7e049 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt @@ -0,0 +1,53 @@ +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.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> { + + private val rateTypeConverter = RateTypeConverter() + + override fun convert(value: SwapPairsWithProviders): List { + val providersAdditionalMap = value.providers.associateBy { it.id } + return value.swapPair.map { pair -> + SwapPairDomain( + from = LeastTokenInfo( + contractAddress = pair.from.contractAddress, + network = pair.from.network, + ), + to = LeastTokenInfo( + contractAddress = pair.to.contractAddress, + network = pair.to.network, + ), + providers = pair.providers.mapNotNull { + convertProvider(it, providersAdditionalMap) + }, + ) + } + } + + private fun convertProvider( + swapPairProvider: SwapPairProvider, + providerAdditional: Map, + ): SwapPairProviderDomain? { + val additionalProvider = providerAdditional[swapPairProvider.providerId] ?: return null + return SwapPairProviderDomain( + providerId = swapPairProvider.providerId, + rateTypes = swapPairProvider.rateTypes.map { rateTypeConverter.convert(it) }, + name = additionalProvider.name, + type = convertExchangeType(additionalProvider.type), + imageLarge = additionalProvider.imageLargeUrl, + ) + } + + private fun convertExchangeType(type: ExchangeProviderType): ExchangeProviderTypeDomain { + return when (type) { + ExchangeProviderType.DEX -> ExchangeProviderTypeDomain.DEX + ExchangeProviderType.CEX -> ExchangeProviderTypeDomain.CEX + } + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index 81931ca111..b30b20f817 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -1,12 +1,20 @@ package com.tangem.feature.swap.di +import com.squareup.moshi.Moshi +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.response.ExpressErrorResponse import com.tangem.datasource.api.oneinch.OneInchApiFactory -import com.tangem.datasource.api.oneinch.OneInchErrorsHandler import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.feature.swap.SwapRepositoryImpl +import com.tangem.feature.swap.converters.ErrorsDataConverter +import com.tangem.feature.swap.DefaultSwapTransactionRepository import com.tangem.feature.swap.domain.SwapRepository +import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -16,25 +24,44 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -class SwapDataModule { +internal class SwapDataModule { @Provides @Singleton - fun provideSwapRepository( + internal fun provideSwapRepository( tangemTechApi: TangemTechApi, + tangemExpressApi: TangemExpressApi, oneInchApiFactory: OneInchApiFactory, - oneInchErrorsHandler: OneInchErrorsHandler, coroutineDispatcher: CoroutineDispatcherProvider, configManager: ConfigManager, walletManagerFacade: WalletManagersFacade, + walletsStateHolder: WalletsStateHolder, + errorsDataConverter: ErrorsDataConverter, ): SwapRepository { return SwapRepositoryImpl( tangemTechApi = tangemTechApi, + tangemExpressApi = tangemExpressApi, oneInchApiFactory = oneInchApiFactory, - oneInchErrorsHandler = oneInchErrorsHandler, coroutineDispatcher = coroutineDispatcher, configManager = configManager, walletManagersFacade = walletManagerFacade, + walletsStateHolder = walletsStateHolder, + errorsDataConverter = errorsDataConverter, ) } + + @Provides + @Singleton + fun provideSwapTransactionRepository(appPreferencesStore: AppPreferencesStore): SwapTransactionRepository { + return DefaultSwapTransactionRepository( + appPreferencesStore = appPreferencesStore, + ) + } + + @Provides + @Singleton + internal fun provideErrorsConverter(@NetworkMoshi moshi: Moshi): ErrorsDataConverter { + val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java) + return ErrorsDataConverter(jsonAdapter) + } } \ No newline at end of file diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index b757775fd0..f3841b400d 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -23,9 +23,15 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) + implementation(projects.domain.transaction) + implementation(projects.domain.legacy) + implementation(projects.domain.demo) + implementation(projects.domain.card) + implementation(projects.domain.appCurrency.models) /** Core modules */ implementation(projects.core.utils) + implementation(projects.core.ui) /** Feature Apis */ implementation(projects.features.wallet.api) @@ -35,4 +41,6 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) implementation(deps.timber) + implementation(deps.tangem.blockchain) + implementation(deps.moshi) } \ No newline at end of file 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 9830aea157..8883e0f1a3 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 @@ -1,12 +1,9 @@ package com.tangem.feature.swap.domain -import com.tangem.feature.swap.domain.models.domain.Currency import com.tangem.feature.swap.domain.models.domain.NetworkInfo interface BlockchainInteractor { - fun getTokenDecimals(token: Currency): Int - /** * In app blockchain id, actual in blockchain sdk, not the same as networkId * diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt similarity index 67% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractorImpl.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt index 969b20c5b3..836f193fe3 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt @@ -1,11 +1,10 @@ package com.tangem.feature.swap.domain -import com.tangem.feature.swap.domain.models.domain.Currency import com.tangem.feature.swap.domain.models.domain.NetworkInfo import com.tangem.lib.crypto.TransactionManager import javax.inject.Inject -internal class BlockchainInteractorImpl @Inject constructor( +internal class DefaultBlockchainInteractor @Inject constructor( private val transactionManager: TransactionManager, ) : BlockchainInteractor { @@ -22,12 +21,4 @@ internal class BlockchainInteractorImpl @Inject constructor( override fun getExplorerTransactionLink(networkId: String, txAddress: String): String { return transactionManager.getExplorerTransactionLink(networkId, txAddress) } - - override fun getTokenDecimals(token: Currency): Int { - return if (token is Currency.NonNativeToken) { - token.decimalCount - } else { - transactionManager.getNativeTokenDecimals(token.networkId) - } - } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt new file mode 100644 index 0000000000..42e6f2331b --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt @@ -0,0 +1,33 @@ +package com.tangem.feature.swap.domain + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress +import java.math.BigDecimal + +internal class DefaultInitialToCurrencyResolver( + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val swapTransactionRepository: SwapTransactionRepository, +) : InitialToCurrencyResolver { + + override suspend fun tryGetFromCache( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + ): CryptoCurrencyStatus? { + val selectedId = getSelectedWalletSyncUseCase().getOrNull() ?: return null + val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(selectedId.walletId) ?: return null + + return if (id != initialCryptoCurrency.id.value) { + state.toGroup.available.find { it.currencyStatus.currency.id.value == id }?.currencyStatus + } else { + null + } + } + + override fun tryGetWithMaxAmount(state: TokensDataStateExpress): CryptoCurrencyStatus? { + return state.toGroup.available.maxByOrNull { + it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO + }?.currencyStatus + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt new file mode 100644 index 0000000000..da0b0f1b37 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt @@ -0,0 +1,15 @@ +package com.tangem.feature.swap.domain + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress + +interface InitialToCurrencyResolver { + + suspend fun tryGetFromCache( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + ): CryptoCurrencyStatus? + + fun tryGetWithMaxAmount(state: TokensDataStateExpress): CryptoCurrencyStatus? +} \ 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 56c1766da8..d36610f673 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 @@ -1,42 +1,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.Currency -import com.tangem.feature.swap.domain.models.domain.PermissionOptions +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* -import java.math.BigDecimal interface SwapInteractor { - fun initDerivationPathAndNetwork(derivationPath: String?, network: Network?) + suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress - /** - * Init tokens to swap, load tokens list available to swap for given network - * - * @param initialCurrency currency which to swap or receive - * @return [TokensDataState] that contains info about all available to swap tokens for networkId - * and preselected tokens which initially select to swap - */ - suspend fun initTokensToSwap(initialCurrency: Currency): TokensDataState - - /** - * On search token, locally search tokens in previously loaded list to swap - * searching in names and symbols - * - * @param networkId networkId for tokens - * @param searchQuery string query for search - * @return [FoundTokensState] that contains list of tokens matching condition query - */ - suspend fun searchTokens(networkId: String, searchQuery: String): FoundTokensState - - /** - * Find specific token by id, null if not found - * - * @param id token id - * @return [Currency] or null - */ - fun findTokenById(id: String): Currency? + fun initDerivationPathAndNetwork(derivationPath: String?, network: Network) /** * Gives permission to swap, this starts scan card process @@ -61,11 +37,12 @@ interface SwapInteractor { @Throws(IllegalStateException::class) suspend fun findBestQuote( networkId: String, - fromToken: Currency, - toToken: Currency, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + providers: List, amountToSwap: String, selectedFee: FeeType = FeeType.NORMAL, - ): SwapState + ): Map /** * Starts swap transaction, perform sign transaction @@ -81,30 +58,39 @@ interface SwapInteractor { @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun onSwap( + swapProvider: SwapProvider, networkId: String, - swapStateData: SwapStateData, - currencyToSend: Currency, - currencyToGet: Currency, + swapData: SwapDataModel?, + currencyToSend: CryptoCurrencyStatus, + currencyToGet: CryptoCurrencyStatus, amountToSwap: String, + includeFeeInAmount: IncludeFeeInAmount, fee: TxFee, ): TxState + suspend fun updateQuotesStateWithSelectedFee( + state: SwapState.QuotesLoadedState, + selectedFee: FeeType, + fromToken: CryptoCurrencyStatus, + amountToSwap: String, + networkId: String, + ): SwapState.QuotesLoadedState + /** * Returns token in wallet balance * - * @param networkId * @param token */ - fun getTokenBalance(networkId: String, token: Currency): SwapAmount + fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount fun isAvailableToSwap(networkId: String): Boolean - fun getSwapAmountForToken(amount: String, token: Currency): SwapAmount + fun getSelectedWallet(): UserWallet? - suspend fun checkFeeIsEnough( - fee: BigDecimal?, - spendAmount: SwapAmount, - networkId: String, - fromToken: Currency, - ): Boolean + suspend fun selectInitialCurrencyToSwap( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + ): CryptoCurrencyStatus? + + fun getNativeToken(networkId: String): CryptoCurrency } \ No newline at end of file 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 4e6dcfa4cf..38ff3ef4af 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,46 +1,61 @@ package com.tangem.feature.swap.domain -import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import arrow.core.getOrElse +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.transaction.Fee +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.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.NetworksRepository +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.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.cache.SwapDataCache import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter 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.domain.Currency import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.lib.crypto.TransactionManager import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.* import com.tangem.lib.crypto.models.transactions.SendTxResult +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.toFiatString +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.firstOrNull import timber.log.Timber import java.math.BigDecimal import java.math.RoundingMode import javax.inject.Inject -import com.tangem.lib.crypto.models.Currency as LibCurrency @Suppress("LargeClass", "LongParameterList") internal class SwapInteractorImpl @Inject constructor( private val transactionManager: TransactionManager, private val userWalletManager: UserWalletManager, private val repository: SwapRepository, - private val cache: SwapDataCache, private val allowPermissionsHandler: AllowPermissionsHandler, - private val currenciesRepository: CurrenciesRepository, - private val networksRepository: NetworksRepository, - private val walletFeatureToggles: WalletFeatureToggles, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + private val walletManagersFacade: WalletManagersFacade, + private val sendTransactionUseCase: SendTransactionUseCase, + private val quotesRepository: QuotesRepository, + private val dispatcher: CoroutineDispatcherProvider, + private val swapTransactionRepository: SwapTransactionRepository, + private val initialToCurrencyResolver: InitialToCurrencyResolver, ) : SwapInteractor { - // TODO: Move to DI - private val addCryptoCurrenciesUseCase by lazy(LazyThreadSafetyMode.NONE) { - AddCryptoCurrenciesUseCase(currenciesRepository, networksRepository) + private val getFeeUseCase by lazy(LazyThreadSafetyMode.NONE) { + GetFeeUseCase(walletManagersFacade, dispatcher) } private val swapCurrencyConverter = SwapCurrencyConverter() @@ -48,96 +63,135 @@ internal class SwapInteractorImpl @Inject constructor( private var derivationPath: String? = null private var network: Network? = null - override fun initDerivationPathAndNetwork(derivationPath: String?, network: Network?) { + override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { + val selectedWallet = getSelectedWalletSyncUseCase().fold( + ifLeft = { null }, + ifRight = { it }, + ) + + requireNotNull(selectedWallet) { "No selected wallet" } + + val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase(selectedWallet.walletId) + .getOrElse { emptyList() } + + val walletCurrencyStatusesExceptInitial = walletCurrencyStatuses.filter { + it.currency.network.backendId != currency.network.backendId || + it.currency.getContractAddress() != currency.getContractAddress() + } + + if (walletCurrencyStatusesExceptInitial.isEmpty()) { + return TokensDataStateExpress( + fromGroup = CurrenciesGroup(emptyList(), emptyList()), + toGroup = CurrenciesGroup(emptyList(), emptyList()), + ) + } + + val pairsLeast = getPairs( + initialCurrency = LeastTokenInfo( + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = currency.network.backendId, + ), + currenciesList = walletCurrencyStatusesExceptInitial.map { it.currency }, + ) + + return TokensDataStateExpress( + fromGroup = getToCurrenciesGroup( + currency = currency, + leastPairs = pairsLeast, + cryptoCurrenciesList = walletCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.from }, + tokenInfoForAvailable = { it.to }, + ), + toGroup = getToCurrenciesGroup( + currency = currency, + leastPairs = pairsLeast, + cryptoCurrenciesList = walletCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.to }, + tokenInfoForAvailable = { it.from }, + ), + ) + } + + override fun getSelectedWallet(): UserWallet? { + return getSelectedWalletSyncUseCase().getOrNull() + } + + private fun getToCurrenciesGroup( + currency: CryptoCurrency, + leastPairs: List, + cryptoCurrenciesList: List, + tokenInfoForFilter: (SwapPairLeast) -> LeastTokenInfo, + tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, + ): CurrenciesGroup { + val filteredPairs = leastPairs.filter { + tokenInfoForFilter(it).contractAddress == currency.getContractAddress() && + tokenInfoForFilter(it).network == currency.network.backendId + } + + val availableCryptoCurrencies = cryptoCurrenciesList.mapNotNull { pair -> + val providers = findProvidersForPair(pair, filteredPairs, tokenInfoForAvailable) + if (providers != null) { + CryptoCurrencySwapInfo(pair, providers) + } else { + null + } + } + + val unavailableCryptoCurrencies = cryptoCurrenciesList - availableCryptoCurrencies + .map { it.currencyStatus } + .toSet() + + return CurrenciesGroup( + available = availableCryptoCurrencies, + unavailable = unavailableCryptoCurrencies.map { CryptoCurrencySwapInfo(it, emptyList()) }, + ) + } + + private fun findProvidersForPair( + cryptoCurrencyStatuses: CryptoCurrencyStatus, + swapPairsLeastList: List, + tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, + ): List? { + return swapPairsLeastList.firstNotNullOfOrNull { + val listTokenInfo = tokenInfoForAvailable(it) + if (cryptoCurrencyStatuses.currency.network.backendId == listTokenInfo.network && + cryptoCurrencyStatuses.currency.getContractAddress() == listTokenInfo.contractAddress + ) { + it.providers + } else { + null + } + } + } + + private fun CryptoCurrency.getContractAddress(): String { + return when (this) { + is CryptoCurrency.Token -> this.contractAddress + is CryptoCurrency.Coin -> "0" + } + } + + private suspend fun getPairs( + initialCurrency: LeastTokenInfo, + currenciesList: List, + ): List { + return repository.getPairs(initialCurrency, currenciesList) + } + + @Deprecated("used in old swap mechanism") + override fun initDerivationPathAndNetwork(derivationPath: String?, network: Network) { this.derivationPath = derivationPath this.network = network } - override suspend fun initTokensToSwap(initialCurrency: Currency): TokensDataState { - // TODO: refactor this function - val networkId = initialCurrency.networkId - val availableTokens = cache.getAvailableTokens(networkId) - val allLoadedTokens = availableTokens.ifEmpty { - val tokens = repository.getExchangeableTokens(networkId) - cache.cacheAvailableToSwapTokens(networkId, tokens) - tokens - }.filter { it.symbol != initialCurrency.symbol } - - // replace tokens in wallet tokens list with loaded same - val loadedOnWalletsMap = mutableSetOf() - val tokensInWallet = userWalletManager.getUserTokens( - networkId = networkId, - derivationPath = derivationPath, - isExcludeCustom = true, - ) - .map { token -> - val contractAddress = (token as? LibCurrency.NonNativeToken)?.contractAddress - allLoadedTokens.firstOrNull { - if (it is Currency.NonNativeToken) { - it.symbol == token.symbol && it.contractAddress == contractAddress - } else { - it.symbol == token.symbol - } - }?.let { - loadedOnWalletsMap.add(it.symbol) - it - } ?: swapCurrencyConverter.convertBack(token) - } - .filter { it.symbol != initialCurrency.symbol && allLoadedTokens.contains(it) } - val loadedTokens = allLoadedTokens - .filter { - !loadedOnWalletsMap.contains(it.symbol) - } - val tokensBalance = userWalletManager.getCurrentWalletTokensBalance(networkId, emptyList(), derivationPath) - .mapValues { SwapAmount(it.value.value, it.value.decimals) } - val appCurrency = userWalletManager.getUserAppCurrency() - val rates = repository.getRates(appCurrency.code, tokensInWallet.map { it.id }) - cache.cacheBalances(networkId, derivationPath, tokensBalance) - cache.cacheLoadedTokens(loadedTokens.map { TokenWithBalance(it) }) - cache.cacheInWalletTokens(getTokensWithBalance(tokensInWallet, tokensBalance, rates, appCurrency)) - return TokensDataState( - preselectTokens = PreselectTokens( - fromToken = initialCurrency, - toToken = selectToToken(initialCurrency, tokensInWallet, loadedTokens), - ), - foundTokensState = FoundTokensState( - tokensInWallet = cache.getInWalletTokens(), - loadedTokens = cache.getLoadedTokens(), - ), - ) - } - - override suspend fun searchTokens(networkId: String, searchQuery: String): FoundTokensState { - val searchQueryLowerCase = searchQuery.lowercase() - val tokensInWallet = cache.getInWalletTokens() - .filter { - it.token.name.lowercase().contains(searchQueryLowerCase) || - it.token.symbol.lowercase().contains(searchQueryLowerCase) - } - val loadedTokens = cache.getLoadedTokens() - .filter { - it.token.name.lowercase().contains(searchQueryLowerCase) || - it.token.symbol.lowercase().contains(searchQueryLowerCase) - } - return FoundTokensState( - tokensInWallet = tokensInWallet, - loadedTokens = loadedTokens, - ) - } - - override fun findTokenById(id: String): Currency? { - val tokensInWallet = cache.getInWalletTokens() - val loadedTokens = cache.getLoadedTokens() - return tokensInWallet.firstOrNull { it.token.id == id }?.token - ?: loadedTokens.firstOrNull { it.token.id == id }?.token - } - + @Deprecated("used in old swap mechanism") override suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): TxState { val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) { getApproveData( networkId = networkId, derivationPath = derivationPath, fromToken = permissionOptions.fromToken, + spenderAddress = permissionOptions.spenderAddress, ) } else { permissionOptions.approveData.approveData @@ -160,7 +214,10 @@ internal class SwapInteractorImpl @Inject constructor( return when (result) { is SendTxResult.Success -> { allowPermissionsHandler.addAddressToInProgress(permissionOptions.forTokenContractAddress) - TxState.TxSent(txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath) ?: "") + TxState.TxSent( + txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), + timestamp = System.currentTimeMillis(), + ) } SendTxResult.UserCancelledError -> TxState.UserCancelled is SendTxResult.BlockchainSdkError -> TxState.BlockchainError @@ -170,56 +227,208 @@ internal class SwapInteractorImpl @Inject constructor( } } + @Deprecated("used in old swap mechanism") override suspend fun findBestQuote( networkId: String, - fromToken: Currency, - toToken: Currency, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + providers: List, amountToSwap: String, selectedFee: FeeType, - ): SwapState { - syncWalletBalanceForTokens(networkId, listOf(fromToken, toToken)) - val amountDecimal = toBigDecimalOrNull(amountToSwap) - if (amountDecimal == null || amountDecimal.signum() == 0) { - return createEmptyAmountState(networkId, fromToken, toToken) + ): Map { + return providers.map { provider -> + val amountDecimal = toBigDecimalOrNull(amountToSwap) + if (amountDecimal == null || amountDecimal.signum() == 0) { + return providers.associateWith { + createEmptyAmountState(fromToken, toToken) + } + } + val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency)) + val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) + + when (provider.type) { + ExchangeProviderType.DEX -> { + manageDex( + networkId = networkId, + fromToken = fromToken, + toToken = toToken, + provider = provider, + selectedFee = selectedFee, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + ) + } + ExchangeProviderType.CEX -> { + manageCex( + networkId = networkId, + fromToken = fromToken, + toToken = toToken, + provider = provider, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + selectedFee = selectedFee, + ) + } + } + }.toMap() + } + + private suspend fun manageDex( + networkId: String, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + provider: SwapProvider, + selectedFee: FeeType, + amount: SwapAmount, + isBalanceWithoutFeeEnough: Boolean, + ): Pair { + val quotes = repository.findBestQuote( + fromContractAddress = fromToken.currency.getContractAddress(), + fromNetwork = fromToken.currency.network.backendId, + toContractAddress = toToken.currency.getContractAddress(), + toNetwork = toToken.currency.network.backendId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = toToken.currency.decimals, + providerId = provider.providerId, + rateType = RateType.FLOAT, + ) + + 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 amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken)) - val fromTokenAddress = getTokenAddress(fromToken) - val toTokenAddress = getTokenAddress(toToken) - val isAllowedToSpend = isAllowedToSpend(networkId, fromToken, amount) + if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) transactionManager.updateWalletManager(networkId, derivationPath) } - val isBalanceWithoutFeeEnough = isBalanceEnough(networkId, fromToken, amount, null) return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { - loadSwapData( + provider to loadDexSwapData( + provider = provider, networkId = networkId, - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, fromToken = fromToken, toToken = toToken, amount = amount, selectedFee = selectedFee, ) } else { - loadQuoteData( - networkId = networkId, - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, + provider to getQuotesState( + exchangeProviderType = ExchangeProviderType.DEX, + quoteDataModel = quotes, amount = amount, fromToken = fromToken, toToken = toToken, + networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + txFee = TxFeeState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex ) } } - override suspend fun onSwap( + private suspend fun manageCex( networkId: String, - swapStateData: SwapStateData, - currencyToSend: Currency, - currencyToGet: Currency, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + provider: SwapProvider, + amount: SwapAmount, + isBalanceWithoutFeeEnough: Boolean, + selectedFee: FeeType, + ): Pair { + return provider to loadCexQuoteData( + exchangeProviderType = ExchangeProviderType.CEX, + networkId = networkId, + amount = amount, + fromTokenStatus = fromToken, + toTokenStatus = toToken, + isAllowedToSpend = true, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + provider = provider, + selectedFee = selectedFee, + ) + } + + override suspend fun onSwap( + swapProvider: SwapProvider, + networkId: String, + swapData: SwapDataModel?, + currencyToSend: CryptoCurrencyStatus, + currencyToGet: CryptoCurrencyStatus, + amountToSwap: String, + includeFeeInAmount: IncludeFeeInAmount, + fee: TxFee, + ): TxState { + return when (swapProvider.type) { + ExchangeProviderType.CEX -> { + val amountDecimal = toBigDecimalOrNull(amountToSwap) + val amount = SwapAmount(requireNotNull(amountDecimal), getTokenDecimals(currencyToSend.currency)) + val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { + includeFeeInAmount.amountSubtractFee + } else { + amount + } + onSwapCex( + currencyToSend = currencyToSend, + currencyToGet = currencyToGet, + amount = amountToSwapWithFee, + txFee = fee, + swapProvider = swapProvider, + userWalletId = requireNotNull(getSelectedWallet()).walletId, + ) + } + ExchangeProviderType.DEX -> { + onSwapDex( + networkId = networkId, + swapData = requireNotNull(swapData), + currencyToSend = currencyToSend.currency, + currencyToGet = currencyToGet.currency, + amountToSwap = amountToSwap, + fee = fee, + ) + } + } + } + + override suspend fun updateQuotesStateWithSelectedFee( + state: SwapState.QuotesLoadedState, + selectedFee: FeeType, + fromToken: CryptoCurrencyStatus, + amountToSwap: String, + networkId: String, + ): SwapState.QuotesLoadedState { + val amountDecimal = toBigDecimalOrNull(amountToSwap) + if (amountDecimal == null || amountDecimal.signum() == 0) { + return state + } + val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency)) + val includeFeeInAmount = getIncludeFeeInAmount( + networkId = networkId, + 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, + includeFeeInAmount = includeFeeInAmount, + ), + ) + } + + private suspend fun onSwapDex( + networkId: String, + swapData: SwapDataModel, + currencyToSend: CryptoCurrency, + currencyToGet: CryptoCurrency, amountToSwap: String, fee: TxFee, ): TxState { @@ -232,8 +441,8 @@ internal class SwapInteractorImpl @Inject constructor( currencyToSend = swapCurrencyConverter.convert(currencyToSend), feeAmount = fee.feeValue, gasLimit = fee.gasLimit, - destinationAddress = swapStateData.swapModel.transaction.toWalletAddress, - dataToSign = swapStateData.swapModel.transaction.data, + destinationAddress = swapData.transaction.txTo, + dataToSign = (swapData.transaction as ExpressTransactionModel.DEX).txData, ), isSwap = true, derivationPath = derivationPath, @@ -244,21 +453,18 @@ internal class SwapInteractorImpl @Inject constructor( ) return when (result) { is SendTxResult.Success -> { - if (walletFeatureToggles.isRedesignedScreenEnabled) { - onSuccessNewFlow(currencyToGet) - } else { - onSuccessLegacyFlow(currencyToGet) - } + storeLastCryptoCurrencyId(currencyToGet) TxState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( amount, currencyToSend.symbol, ), toAmount = amountFormatter.formatSwapAmountToUI( - swapStateData.swapModel.toTokenAmount, + swapData.toTokenAmount, currencyToGet.symbol, ), - txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath) ?: "", + txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), + timestamp = System.currentTimeMillis(), ) } SendTxResult.UserCancelledError -> TxState.UserCancelled @@ -269,112 +475,186 @@ internal class SwapInteractorImpl @Inject constructor( } } - override fun getTokenBalance(networkId: String, token: Currency): SwapAmount { - return cache.getBalanceForToken( - networkId = networkId, - derivationPath = derivationPath, - symbol = token.symbol, - ) ?: SwapAmount(BigDecimal.ZERO, getTokenDecimals(token)) - } + private suspend fun onSwapCex( + currencyToSend: CryptoCurrencyStatus, + currencyToGet: CryptoCurrencyStatus, + amount: SwapAmount, + txFee: TxFee, + swapProvider: SwapProvider, + userWalletId: UserWalletId, + ): TxState { + val exchangeData = repository.getExchangeData( + fromContractAddress = currencyToSend.currency.getContractAddress(), + fromNetwork = currencyToSend.currency.network.backendId, + toContractAddress = currencyToGet.currency.getContractAddress(), + toNetwork = currencyToGet.currency.network.backendId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = currencyToGet.currency.decimals, + providerId = swapProvider.providerId, + rateType = RateType.FLOAT, + toAddress = currencyToGet.value.networkAddress?.defaultAddress?.value ?: "", + ) - override fun isAvailableToSwap(networkId: String): Boolean { - return ONE_INCH_SUPPORTED_NETWORKS.contains(networkId) - } + val txData = walletManagersFacade.createTransaction( + amount = amount.value.convertToAmount(currencyToSend.currency), + fee = getFeeForTransaction(txFee), + memo = null, + destination = (exchangeData.dataModel?.transaction as ExpressTransactionModel.CEX).txTo, + userWalletId = userWalletId, + network = currencyToSend.currency.network, + ) - override fun getSwapAmountForToken(amount: String, token: Currency): SwapAmount { - val amountDecimal = requireNotNull(toBigDecimalOrNull(amount)) { "wrong amount format" } - return SwapAmount(amountDecimal, getTokenDecimals(token)) - } + val result = sendTransactionUseCase( + requireNotNull(txData), + userWallet = requireNotNull(getSelectedWallet()), + network = currencyToSend.currency.network, + ) - private suspend fun onSuccessLegacyFlow(currency: Currency) { - userWalletManager.addToken(swapCurrencyConverter.convert(currency), derivationPath) - userWalletManager.refreshWallet() - } + val externalUrl = (exchangeData.dataModel.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl - private suspend fun onSuccessNewFlow(currency: Currency) { - val network = network ?: return - getSelectedWalletSyncUseCase().fold( - ifRight = { userWallet -> - getAndAddCryptoCurrency(userWallet, currency, network) - }, + return result.fold( ifLeft = { - Timber.e("Swap Error on getSelectedWalletUseCase") + when (it) { + SendTransactionError.UserCancelledError -> TxState.UserCancelled + is SendTransactionError.BlockchainSdkError -> TxState.BlockchainError + is SendTransactionError.TangemSdkError -> TxState.TangemSdkError + is SendTransactionError.NetworkError -> TxState.NetworkError + else -> TxState.UnknownError + } + }, + ifRight = { + val timestamp = System.currentTimeMillis() + storeSwapTransaction( + currencyToSend = currencyToSend, + currencyToGet = currencyToGet, + amount = amount, + swapProvider = swapProvider, + swapDataModel = exchangeData.dataModel, + timestamp = timestamp, + ) + storeLastCryptoCurrencyId(currencyToGet.currency) + TxState.TxSent( + fromAmount = amountFormatter.formatSwapAmountToUI( + amount, + currencyToSend.currency.symbol, + ), + toAmount = amountFormatter.formatSwapAmountToUI( + exchangeData.dataModel.toTokenAmount, + currencyToGet.currency.symbol, + ), + txAddress = userWalletManager.getLastTransactionHash( + currencyToSend.currency.network.backendId, + derivationPath, + ).orEmpty(), + txExternalUrl = externalUrl, + timestamp = timestamp, + ) }, ) } - private suspend fun getAndAddCryptoCurrency(userWallet: UserWallet, currency: Currency, network: Network) { - repository.getCryptoCurrency(userWallet, currency, network)?.let { cryptoCurrency -> - addCryptoCurrenciesUseCase(userWallet.walletId, cryptoCurrency) + private fun getFeeForTransaction(fee: TxFee): Fee { + val feeAmountValue = fee.feeValue + val feeAmount = Amount( + value = fee.feeValue, + currencySymbol = fee.cryptoSymbol, + decimals = fee.decimals, + type = AmountType.Coin, + ) + + return if (fee.gasLimit != 0) { + Fee.Ethereum( + amount = feeAmount, + gasLimit = fee.gasLimit.toBigInteger(), + gasPrice = (feeAmountValue / fee.gasLimit.toBigDecimal()).toBigInteger(), + ) + } else { + Fee.Common(feeAmount) } } + private suspend fun storeSwapTransaction( + currencyToSend: CryptoCurrencyStatus, + currencyToGet: CryptoCurrencyStatus, + amount: SwapAmount, + swapProvider: SwapProvider, + swapDataModel: SwapDataModel, + timestamp: Long, + ) { + swapTransactionRepository.storeTransaction( + userWalletId = UserWalletId(userWalletManager.getWalletId()), + fromCryptoCurrencyId = currencyToSend.currency.id, + toCryptoCurrencyId = currencyToGet.currency.id, + transaction = SavedSwapTransactionModel( + txId = swapDataModel.transaction.txId, + provider = swapProvider, + timestamp = timestamp, + fromCryptoAmount = amount.value, + toCryptoAmount = swapDataModel.toTokenAmount.value, + ), + ) + } + + private suspend fun storeLastCryptoCurrencyId(cryptoCurrency: CryptoCurrency) { + swapTransactionRepository.storeLastSwappedCryptoCurrencyId( + UserWalletId(userWalletManager.getWalletId()), + cryptoCurrency.id, + ) + } + + @Deprecated("used in old swap mechanism") + override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount { + return SwapAmount(token.value.amount ?: BigDecimal.ZERO, getTokenDecimals(token.currency)) + } + + @Deprecated("used in old swap mechanism") + override fun isAvailableToSwap(networkId: String): Boolean { + return ONE_INCH_SUPPORTED_NETWORKS.contains(networkId) + } + + override suspend fun selectInitialCurrencyToSwap( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + ): CryptoCurrencyStatus? { + return initialToCurrencyResolver.tryGetFromCache(initialCryptoCurrency, state) + ?: initialToCurrencyResolver.tryGetWithMaxAmount(state) + ?: state.toGroup.available.firstOrNull()?.currencyStatus + } + + override fun getNativeToken(networkId: String): CryptoCurrency { + return repository.getNativeTokenForNetwork(networkId) + } + + @Deprecated("used in old swap mechanism") private fun getTangemFee(): Double { return repository.getTangemFee() } - private fun getTokenDecimals(token: Currency): Int { - return if (token is Currency.NonNativeToken) { - token.decimalCount + private fun getTokenDecimals(token: CryptoCurrency): Int { + return if (token is CryptoCurrency.Token) { + token.decimals } else { - transactionManager.getNativeTokenDecimals(token.networkId) + transactionManager.getNativeTokenDecimals(token.network.backendId) } } - private fun selectToToken( - initialToken: Currency, - tokensInWallet: List, - loadedTokens: List, - ): Currency { - val toToken = if (tokensInWallet.isNotEmpty()) { - tokensInWallet.firstOrNull { it.symbol != initialToken.symbol } - ?: loadedTokens.first { it.symbol != initialToken.symbol } - } else { - val findUsdt = loadedTokens.firstOrNull { it.symbol == USDT_SYMBOL && it.symbol != initialToken.symbol } - if (findUsdt == null) { - val findUsdc = loadedTokens.firstOrNull { it.symbol == USDC_SYMBOL && it.symbol != initialToken.symbol } - findUsdc ?: loadedTokens.first { it.symbol != initialToken.symbol } - } else { - findUsdt - } - } - return toToken - } - - private fun getTokensWithBalance( - tokens: List, - balances: Map, - rates: Map, - appCurrency: ProxyFiatCurrency, - ): List { - return tokens.map { - val balance = balances[it.symbol] - TokenWithBalance( - token = it, - tokenBalanceData = TokenBalanceData( - amount = balance?.let { amount -> - amountFormatter.formatSwapAmountToUI(amount, it.symbol) - }, - amountEquivalent = balance?.value?.toFiatString( - rateValue = rates[it.id]?.toBigDecimal() ?: BigDecimal.ZERO, - fiatCurrencyName = appCurrency.symbol, - formatWithSpaces = true, - ), - ), - ) - } - } - - private suspend fun isAllowedToSpend(networkId: String, fromToken: Currency, amount: SwapAmount): Boolean { - if (fromToken is Currency.NativeToken) return true + private suspend fun isAllowedToSpend( + networkId: String, + fromToken: CryptoCurrency, + amount: SwapAmount, + spenderAddress: String, + ): Boolean { + if (fromToken is CryptoCurrency.Coin) return true return getSelectedWalletSyncUseCase().fold( ifRight = { userWallet -> val allowance = repository.getAllowance( - userWallet.walletId, - networkId, - derivationPath, - getTokenDecimals(fromToken), - getTokenAddress(fromToken), + userWalletId = userWallet.walletId, + networkId = networkId, + derivationPath = derivationPath, + tokenDecimalCount = getTokenDecimals(fromToken), + tokenAddress = getTokenAddress(fromToken), + spenderAddress = spenderAddress, ) allowance >= amount.value }, @@ -385,13 +665,13 @@ internal class SwapInteractorImpl @Inject constructor( ) } - private fun createEmptyAmountState(networkId: String, fromToken: Currency, toToken: Currency): SwapState { + private fun createEmptyAmountState(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): SwapState { val appCurrency = userWalletManager.getUserAppCurrency() - val fromTokenBalance = cache.getBalanceForToken(networkId, derivationPath, fromToken.symbol) - val toTokenBalance = cache.getBalanceForToken(networkId, derivationPath, toToken.symbol) + val fromTokenBalance = getTokenBalance(fromToken) + val toTokenBalance = getTokenBalance(toToken) return SwapState.EmptyAmountState( - fromTokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }.orEmpty(), - toTokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }.orEmpty(), + fromTokenWalletBalance = amountFormatter.formatSwapAmountToUI(fromTokenBalance, ""), + toTokenWalletBalance = amountFormatter.formatSwapAmountToUI(toTokenBalance, ""), zeroAmountEquivalent = BigDecimal.ZERO.toFiatString( rateValue = BigDecimal.ONE, fiatCurrencyName = appCurrency.symbol, @@ -404,57 +684,182 @@ internal class SwapInteractorImpl @Inject constructor( * Load quote data calls only if spend is not allowed for token contract address */ @Suppress("LongParameterList") - private suspend fun loadQuoteData( + private suspend fun loadCexQuoteData( + exchangeProviderType: ExchangeProviderType, networkId: String, - fromTokenAddress: String, - toTokenAddress: String, amount: SwapAmount, - fromToken: Currency, - toToken: Currency, + fromTokenStatus: CryptoCurrencyStatus, + toTokenStatus: CryptoCurrencyStatus, + provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, + selectedFee: FeeType, ): SwapState { - repository.findBestQuote( - networkId = networkId, - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, - amount = amount.toStringWithRightOffset(), - ).let { quotes -> - val quoteDataModel = quotes.dataModel - if (quoteDataModel != null) { - val swapState = updateBalances( - networkId = networkId, - fromToken = fromToken, - toToken = toToken, - fromTokenAmount = amount, - toTokenAmount = quoteDataModel.toTokenAmount, - swapStateData = null, - ) - val quotesState = updatePermissionState( - networkId = networkId, - fromToken = fromToken, - swapAmount = amount, - quotesLoadedState = swapState, - ) - return quotesState.copy( - preparedSwapConfigState = quotesState.preparedSwapConfigState.copy( + val fromToken = fromTokenStatus.currency + val toToken = toTokenStatus.currency + return coroutineScope { + val txFee = if (provider.type == ExchangeProviderType.CEX) { + getFeeForCex(amount, fromTokenStatus, networkId) + } else { + TxFeeState.Empty + } + + val includeFeeInAmount = getIncludeFeeInAmount( + networkId = networkId, + txFee = txFee, + amount = amount, + fromToken = fromToken, + selectedFee = selectedFee, + ) + val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { + includeFeeInAmount.amountSubtractFee + } else { + amount + } + + val quotes = repository.findBestQuote( + fromContractAddress = fromToken.getContractAddress(), + fromNetwork = fromToken.network.backendId, + toContractAddress = toToken.getContractAddress(), + toNetwork = toToken.network.backendId, + fromAmount = amountToRequest.toStringWithRightOffset(), + fromDecimals = amount.decimals, + providerId = provider.providerId, + toDecimals = toToken.decimals, + rateType = RateType.FLOAT, + ) + + getQuotesState( + exchangeProviderType = exchangeProviderType, + quoteDataModel = quotes, + amount = amount, + fromToken = fromTokenStatus, + toToken = toTokenStatus, + networkId = networkId, + isAllowedToSpend = isAllowedToSpend, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + txFee = txFee, + includeFeeInAmount = includeFeeInAmount, + ) + } + } + + private suspend fun getQuotesState( + exchangeProviderType: ExchangeProviderType, + quoteDataModel: AggregatedSwapDataModel, + amount: SwapAmount, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + networkId: String, + isAllowedToSpend: Boolean, + isBalanceWithoutFeeEnough: Boolean, + 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 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, + 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) + } + } + + private suspend fun getIncludeFeeInAmount( + networkId: String, + 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() + + 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.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 SwapState.SwapError(quotes.error) + IncludeFeeInAmount.BalanceNotEnough } } } private suspend fun getFormattedFiatFees(networkId: String, vararg fees: BigDecimal): List { val appCurrency = userWalletManager.getUserAppCurrency() - val nativeToken = userWalletManager.getNativeTokenForNetwork(networkId) - val rates = repository.getRates(appCurrency.code, listOf(nativeToken.id)) - return rates[nativeToken.id]?.toBigDecimal()?.let { rate -> + val nativeToken = repository.getNativeTokenForNetwork(networkId) + val rates = getQuotes(nativeToken.id) + return rates[nativeToken.id]?.fiatRate?.let { rate -> fees.map { fee -> - " (${fee.toFiatString(rate, appCurrency.symbol, true)})" + fee.toFiatString(rate, appCurrency.symbol, true) } }.orEmpty() } @@ -463,57 +868,57 @@ internal class SwapInteractorImpl @Inject constructor( * Load swap data calls only if spend is allowed for token contract address */ @Suppress("LongParameterList") - private suspend fun loadSwapData( + private suspend fun loadDexSwapData( + provider: SwapProvider, networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - fromToken: Currency, - toToken: Currency, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, amount: SwapAmount, selectedFee: FeeType, ): SwapState { - repository.prepareSwapTransaction( - networkId = networkId, - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, - amount = amount.toStringWithRightOffset(), - slippage = DEFAULT_SLIPPAGE, - fromWalletAddress = getWalletAddress(networkId), + repository.getExchangeData( + fromContractAddress = fromToken.currency.getContractAddress(), + fromNetwork = fromToken.currency.network.backendId, + toContractAddress = toToken.currency.getContractAddress(), + toNetwork = toToken.currency.network.backendId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = toToken.currency.decimals, + providerId = provider.providerId, + rateType = RateType.FLOAT, + toAddress = toToken.value.networkAddress?.defaultAddress?.value ?: "", ).let { val swapData = it.dataModel if (swapData != null) { val feeData = transactionManager.getFee( networkId = networkId, amountToSend = amount.value, - currencyToSend = swapCurrencyConverter.convert(fromToken), - destinationAddress = swapData.transaction.toWalletAddress, + currencyToSend = swapCurrencyConverter.convert(fromToken.currency), + destinationAddress = swapData.transaction.txTo, increaseBy = INCREASE_GAS_LIMIT_BY, - data = swapData.transaction.data, + data = (swapData.transaction as ExpressTransactionModel.DEX).txData, derivationPath = derivationPath, ) - val txFeeState = proxyFeesToFeeState(networkId, feeData) - val feeByPriority = when (selectedFee) { - FeeType.NORMAL -> txFeeState.normalFee.feeValue - FeeType.PRIORITY -> txFeeState.priorityFee.feeValue + val txFeeState = when (feeData) { + is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId) + is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId) } - val isBalanceIncludeFeeEnough = - isBalanceEnough(networkId, fromToken, amount, feeByPriority) + val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState) + val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeByPriority) val isFeeEnough = checkFeeIsEnough( fee = feeByPriority, spendAmount = amount, networkId = networkId, - fromToken = fromToken, + fromToken = fromToken.currency, ) val swapState = updateBalances( networkId = networkId, - fromToken = fromToken, - toToken = toToken, + fromTokenStatus = fromToken, + toTokenStatus = toToken, fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, - swapStateData = SwapStateData( - fee = txFeeState, - swapModel = swapData, - ), + swapData = swapData, + txFeeState = txFeeState, ) return swapState.copy( permissionState = PermissionDataState.Empty, @@ -521,10 +926,21 @@ internal class SwapInteractorImpl @Inject constructor( isAllowedToSpend = true, isBalanceEnough = isBalanceIncludeFeeEnough, isFeeEnough = isFeeEnough, + includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex ), ) } else { - return SwapState.SwapError(it.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, + it.error, + ) } } } @@ -532,61 +948,85 @@ internal class SwapInteractorImpl @Inject constructor( @Suppress("LongParameterList") private suspend fun updateBalances( networkId: String, - fromToken: Currency, - toToken: Currency, + fromTokenStatus: CryptoCurrencyStatus, + toTokenStatus: CryptoCurrencyStatus, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, - swapStateData: SwapStateData?, + swapData: SwapDataModel?, + txFeeState: TxFeeState, ): SwapState.QuotesLoadedState { - val appCurrency = userWalletManager.getUserAppCurrency() - val nativeToken = userWalletManager.getNativeTokenForNetwork(networkId) - val rates = repository.getRates(appCurrency.code, listOf(fromToken.id, toToken.id, nativeToken.id)) - val fromTokenBalance = cache.getBalanceForToken(networkId, derivationPath, fromToken.symbol) - val toTokenBalance = cache.getBalanceForToken(networkId, derivationPath, toToken.symbol) + val fromToken = fromTokenStatus.currency + val toToken = toTokenStatus.currency + val nativeToken = repository.getNativeTokenForNetwork(networkId) + + val rates = getQuotes(fromToken.id, toToken.id, nativeToken.id) return SwapState.QuotesLoadedState( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, - coinId = fromToken.id, - tokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") } - ?: ZERO_BALANCE, - tokenFiatBalance = fromTokenAmount.value.toFiatString( - rateValue = rates[fromToken.id]?.toBigDecimal() ?: BigDecimal.ZERO, - fiatCurrencyName = appCurrency.symbol, - formatWithSpaces = true, - ), + cryptoCurrencyStatus = fromTokenStatus, + amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) + ?: BigDecimal.ZERO, ), toTokenInfo = TokenSwapInfo( tokenAmount = toTokenAmount, - coinId = toToken.id, - tokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") } - ?: ZERO_BALANCE, - tokenFiatBalance = toTokenAmount.value.toFiatString( - rateValue = rates[toToken.id]?.toBigDecimal() ?: BigDecimal.ZERO, - fiatCurrencyName = appCurrency.symbol, - formatWithSpaces = true, - ), + cryptoCurrencyStatus = toTokenStatus, + amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) + ?: BigDecimal.ZERO, ), priceImpact = calculatePriceImpact( fromTokenAmount = fromTokenAmount.value, - fromRate = rates[fromToken.id] ?: 0.0, + fromRate = rates[fromToken.id]?.fiatRate?.toDouble() ?: 0.0, toTokenAmount = toTokenAmount.value, - toRate = rates[toToken.id] ?: 0.0, + toRate = rates[toToken.id]?.fiatRate?.toDouble() ?: 0.0, ), networkCurrency = userWalletManager.getNetworkCurrency(networkId), - swapDataModel = swapStateData, + swapDataModel = swapData, tangemFee = getTangemFee(), + txFee = txFeeState, ) } - @Suppress("LongParameterList") + private suspend fun getFeeForCex( + amount: SwapAmount, + fromToken: CryptoCurrencyStatus, + networkId: String, + ): TxFeeState { + getSelectedWalletSyncUseCase().getOrNull()?.walletId?.let { userWalletId -> + val txFeeResult = getFeeUseCase( + amount = amount.value, + destination = fromToken.value.networkAddress?.defaultAddress?.value ?: "", + userWalletId = userWalletId, + cryptoCurrency = fromToken.currency, + ).firstOrNull() + return txFeeResult?.fold( + ifLeft = { + TxFeeState.Empty + }, + ifRight = { txFee -> + txFee.toTxFeeState(networkId) + }, + ) ?: TxFeeState.Empty + } + return TxFeeState.Empty + } + + @Suppress("LongParameterList", "LongMethod") private suspend fun updatePermissionState( networkId: String, - fromToken: Currency, + fromTokenStatus: CryptoCurrencyStatus, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, + spenderAddress: String?, + isAllowedToSpend: Boolean, ): SwapState.QuotesLoadedState { + val fromToken = fromTokenStatus.currency + if (isAllowedToSpend) { + return quotesLoadedState.copy( + permissionState = PermissionDataState.Empty, + ) + } // if token balance ZERO not show permission state to avoid user to spend money for fee - val isTokenZeroBalance = getTokenBalance(networkId, fromToken).value.signum() == 0 + val isTokenZeroBalance = getTokenBalance(fromTokenStatus).value.signum() == 0 if (isTokenZeroBalance) { return quotesLoadedState.copy( permissionState = PermissionDataState.Empty, @@ -603,19 +1043,35 @@ internal class SwapInteractorImpl @Inject constructor( derivationPath = derivationPath, fromToken = fromToken, swapAmount = swapAmount, + spenderAddress = requireNotNull(spenderAddress) { "Spender address is null" }, ) - val feeData = transactionManager.getFee( - networkId = networkId, - amountToSend = BigDecimal.ZERO, - currencyToSend = userWalletManager.getNativeTokenForNetwork(networkId), - destinationAddress = getTokenAddress(fromToken), - increaseBy = INCREASE_GAS_LIMIT_BY, - data = transactionData, - derivationPath = derivationPath, - ) - val feeState = proxyFeesToFeeState(networkId, feeData) + val feeData = try { + transactionManager.getFee( + networkId = networkId, + amountToSend = BigDecimal.ZERO, + currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)), + destinationAddress = fromToken.getContractAddress(), + increaseBy = INCREASE_GAS_LIMIT_BY, + data = transactionData, + derivationPath = derivationPath, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to get fee") + null + } + val feeState = feeData?.let { + when (feeData) { + is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId) + is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId) + } + } ?: TxFeeState.Empty + val fee = when (feeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue + is TxFeeState.SingleFeeState -> feeState.fee.feeValue + } val isFeeEnough = checkFeeIsEnough( - fee = feeData.normalFee.fee.value, + fee = fee, spendAmount = SwapAmount.zeroSwapAmount(), networkId = networkId, fromToken = fromToken, @@ -630,6 +1086,7 @@ internal class SwapInteractorImpl @Inject constructor( fee = feeState, approveData = transactionData, fromTokenAmount = swapAmount, + spenderAddress = spenderAddress, ), ), preparedSwapConfigState = quotesLoadedState.preparedSwapConfigState.copy( @@ -638,47 +1095,32 @@ internal class SwapInteractorImpl @Inject constructor( ) } - private suspend fun syncWalletBalanceForTokens(networkId: String, tokens: List) { - val tokensToSync = tokens.filter { cache.getBalanceForToken(networkId, derivationPath, it.symbol) == null } - if (tokensToSync.isNotEmpty()) { - val tokensBalance = - userWalletManager.getCurrentWalletTokensBalance( - networkId = networkId, - extraTokens = tokensToSync.map { swapCurrencyConverter.convert(it) }, - derivationPath = derivationPath, - ) - cache.cacheBalances( - networkId = networkId, - derivationPath = derivationPath, - balances = tokensBalance.mapValues { SwapAmount(it.value.value, it.value.decimals) }, - ) - } - } - - private suspend fun proxyFeesToFeeState(networkId: String, proxyFees: ProxyFees): TxFeeState { - val normalFeeValue = proxyFees.minFee.fee.value // in swap for normal use min fee - val normalFeeGas = proxyFees.minFee.gasLimit.toInt() - val priorityFeeValue = proxyFees.normalFee.fee.value // in swap for priority use normal fee - val priorityFeeGas = proxyFees.normalFee.gasLimit.toInt() + private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(networkId: String): TxFeeState { + val normalFeeValue = this.minFee.fee.value // in swap for normal use min fee + val normalFeeGas = this.minFee.gasLimit.toInt() + val priorityFeeValue = this.normalFee.fee.value // in swap for priority use normal fee + val priorityFeeGas = this.normalFee.gasLimit.toInt() val feesFiat = getFormattedFiatFees(networkId, normalFeeValue, priorityFeeValue) val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" } val priorityFiatFee = requireNotNull(feesFiat.getOrNull(1)) { "feesFiat item 1 couldn't be null" } + val networkCurrency = userWalletManager.getNetworkCurrency(networkId) + val decimals = transactionManager.getNativeTokenDecimals(networkId) val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeValue, - decimals = transactionManager.getNativeTokenDecimals(networkId), - currency = userWalletManager.getNetworkCurrency(networkId), + decimals = decimals, ) val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = priorityFeeValue, - decimals = transactionManager.getNativeTokenDecimals(networkId), - currency = userWalletManager.getNetworkCurrency(networkId), + decimals = decimals, ) - return TxFeeState( + return TxFeeState.MultipleFeeState( normalFee = TxFee( feeValue = normalFeeValue, gasLimit = normalFeeGas, feeFiatFormatted = normalFiatFee, feeCryptoFormatted = normalCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, feeType = FeeType.NORMAL, ), priorityFee = TxFee( @@ -686,19 +1128,119 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = priorityFeeGas, feeFiatFormatted = priorityFiatFee, feeCryptoFormatted = priorityCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, feeType = FeeType.PRIORITY, ), ) } - private fun isBalanceEnough( - networkId: String, - fromToken: Currency, - amount: SwapAmount, - fee: BigDecimal?, - ): Boolean { - val tokenBalance = getTokenBalance(networkId, fromToken).value - return if (fromToken is Currency.NonNativeToken) { + private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState(networkId: String): TxFeeState { + val normalFeeValue = this.singleFee.fee.value + val normalFeeGas = this.singleFee.gasLimit.toInt() + val networkCurrency = userWalletManager.getNetworkCurrency(networkId) + val feesFiat = getFormattedFiatFees(networkId, normalFeeValue) + val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" } + val decimals = transactionManager.getNativeTokenDecimals(networkId) + val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( + amount = normalFeeValue, + decimals = decimals, + ) + return TxFeeState.SingleFeeState( + fee = TxFee( + feeValue = normalFeeValue, + gasLimit = normalFeeGas, + feeFiatFormatted = normalFiatFee, + feeCryptoFormatted = normalCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, + feeType = FeeType.NORMAL, + ), + ) + } + + private suspend fun TransactionFee.toTxFeeState(networkId: String): TxFeeState { + val networkCurrency = userWalletManager.getNetworkCurrency(networkId) + 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 normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0] + val priorityFiatValue = getFormattedFiatFees(networkId, feePriority)[0] + + val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( + amount = feeNormal, + decimals = decimals, + ) + val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( + amount = feePriority, + decimals = decimals, + ) + TxFeeState.MultipleFeeState( + normalFee = TxFee( + feeValue = feeNormal, + gasLimit = this.normal.getGasLimit(), + feeFiatFormatted = normalFiatValue, + feeCryptoFormatted = normalCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, + feeType = FeeType.NORMAL, + ), + priorityFee = TxFee( + feeValue = feePriority, + gasLimit = this.priority.getGasLimit(), + feeFiatFormatted = priorityFiatValue, + feeCryptoFormatted = priorityCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, + feeType = FeeType.PRIORITY, + ), + ) + } + is TransactionFee.Single -> { + val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO + val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0] + val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( + amount = feeNormal, + decimals = transactionManager.getNativeTokenDecimals(networkId), + ) + TxFeeState.SingleFeeState( + fee = TxFee( + feeValue = this.normal.amount.value ?: BigDecimal.ZERO, + gasLimit = this.normal.getGasLimit(), + feeFiatFormatted = normalFiatValue, + feeCryptoFormatted = normalCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, + feeType = FeeType.NORMAL, + ), + ) + } + } + } + + private fun Fee.getGasLimit(): Int { + return when (this) { + is Fee.Common -> 0 + is Fee.Ethereum -> this.gasLimit.toInt() + } + } + + private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): BigDecimal { + return when (txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.SingleFeeState -> txFeeState.fee.feeValue + is TxFeeState.MultipleFeeState -> when (feeType) { + FeeType.NORMAL -> txFeeState.normalFee.feeValue + FeeType.PRIORITY -> txFeeState.priorityFee.feeValue + } + } + } + + private fun isBalanceEnough(fromToken: CryptoCurrencyStatus, amount: SwapAmount, fee: BigDecimal?): Boolean { + val tokenBalance = getTokenBalance(fromToken).value + return if (fromToken.currency is CryptoCurrency.Token) { tokenBalance >= amount.value } else { tokenBalance > amount.value.plus(fee ?: BigDecimal.ZERO) @@ -709,22 +1251,22 @@ internal class SwapInteractorImpl @Inject constructor( return userWalletManager.getWalletAddress(networkId, derivationPath) } - private fun getTokenAddress(currency: Currency): String { + private fun getTokenAddress(currency: CryptoCurrency): String { return when (currency) { - is Currency.NativeToken -> { - DEFAULT_BLOCKCHAIN_INCH_ADDRESS + is CryptoCurrency.Coin -> { + "0" } - is Currency.NonNativeToken -> { + is CryptoCurrency.Token -> { currency.contractAddress } } } - override suspend fun checkFeeIsEnough( + private suspend fun checkFeeIsEnough( fee: BigDecimal?, spendAmount: SwapAmount, networkId: String, - fromToken: Currency, + fromToken: CryptoCurrency, ): Boolean { if (fee == null) { return false @@ -732,12 +1274,12 @@ internal class SwapInteractorImpl @Inject constructor( val nativeTokenBalance = userWalletManager.getNativeTokenBalance(networkId, derivationPath) val percentsToFeeIncrease = BigDecimal.ONE return when (fromToken) { - is Currency.NativeToken -> { + is CryptoCurrency.Coin -> { nativeTokenBalance?.let { balance -> return balance.value.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease) } ?: false } - is Currency.NonNativeToken -> { + is CryptoCurrency.Token -> { nativeTokenBalance?.let { balance -> return balance.value > fee.multiply(percentsToFeeIncrease) } ?: false @@ -763,8 +1305,9 @@ internal class SwapInteractorImpl @Inject constructor( private suspend fun getApproveData( networkId: String, derivationPath: String?, - fromToken: Currency, + fromToken: CryptoCurrency, swapAmount: SwapAmount? = null, + spenderAddress: String, ): String { return getSelectedWalletSyncUseCase().fold( ifRight = { userWallet -> @@ -774,6 +1317,7 @@ internal class SwapInteractorImpl @Inject constructor( derivationPath = derivationPath, currency = fromToken, amount = swapAmount?.value, + spenderAddress = spenderAddress, ) }, ifLeft = { @@ -783,16 +1327,17 @@ internal class SwapInteractorImpl @Inject constructor( ) } - companion object { - private const val DEFAULT_SLIPPAGE = 2 - private const val ZERO_BALANCE = "0" - private const val DEFAULT_BLOCKCHAIN_INCH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" + private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map { + val set = quotesRepository.getQuotesSync(ids.toSet(), false) + return ids + .mapNotNull { id -> set.find { it.rawCurrencyId == id.rawCurrencyId }?.let { id to it } } + .toMap() + } + + companion object { @Suppress("UnusedPrivateMember") - private const val INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT = 1.0 // if need to increase fee when check isEnough private const val INCREASE_GAS_LIMIT_BY = 112 // 12% - private const val USDT_SYMBOL = "USDT" - private const val USDC_SYMBOL = "USDC" 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/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt index b303325c8c..9f90d4dc3d 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt @@ -1,26 +1,33 @@ package com.tangem.feature.swap.domain +import arrow.core.Either import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.QuoteModel -import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.* import java.math.BigDecimal interface SwapRepository { + suspend fun getPairs(initialCurrency: LeastTokenInfo, currencyList: List): List + suspend fun getRates(currencyId: String, tokenIds: List): Map suspend fun getExchangeableTokens(networkId: String): List + suspend fun getExchangeStatus(txId: String): Either + + @Suppress("LongParameterList") suspend fun findBestQuote( - networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - amount: String, + fromContractAddress: String, + fromNetwork: String, + toContractAddress: String, + toNetwork: String, + fromAmount: String, + fromDecimals: Int, + toDecimals: Int, + providerId: String, + rateType: RateType, ): AggregatedSwapDataModel /** @@ -30,24 +37,13 @@ interface SwapRepository { */ suspend fun addressForTrust(networkId: String): String - @Suppress("LongParameterList") - suspend fun prepareSwapTransaction( - networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - amount: String, - fromWalletAddress: String, - slippage: Int, - ): AggregatedSwapDataModel - /** * Returns a tangem fee for swap in percents * Example: 0.35% */ fun getTangemFee(): Double - suspend fun getCryptoCurrency(userWallet: UserWallet, currency: Currency, network: Network): CryptoCurrency? - + @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun getAllowance( userWalletId: UserWalletId, @@ -55,14 +51,33 @@ interface SwapRepository { derivationPath: String?, tokenDecimalCount: Int, tokenAddress: String, + spenderAddress: String, ): BigDecimal + @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun getApproveData( userWalletId: UserWalletId, networkId: String, derivationPath: String?, - currency: Currency, + currency: CryptoCurrency, amount: BigDecimal?, + spenderAddress: String, ): String + + @Suppress("LongParameterList") + suspend fun getExchangeData( + fromContractAddress: String, + fromNetwork: String, + toContractAddress: String, + toNetwork: String, + fromAmount: String, + fromDecimals: Int, + toDecimals: Int, + providerId: String, + rateType: RateType, + toAddress: String, + ): AggregatedSwapDataModel + + fun getNativeTokenForNetwork(networkId: String): CryptoCurrency } \ No newline at end of file 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 new file mode 100644 index 0000000000..2db58ee01d --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -0,0 +1,33 @@ +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.SavedSwapTransactionListModel +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel +import kotlinx.coroutines.flow.Flow + +interface SwapTransactionRepository { + + suspend fun storeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + transaction: SavedSwapTransactionModel, + ) + + fun getTransactions( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): Flow?> + + suspend fun removeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + txId: String, + ) + + suspend fun storeLastSwappedCryptoCurrencyId(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID) + + suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt deleted file mode 100644 index 7f4bf919cc..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.feature.swap.domain.cache - -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.ui.TokenWithBalance -import java.math.BigDecimal - -interface SwapDataCache { - - fun cacheAvailableToSwapTokens(networkId: String, tokens: List) - fun cacheInWalletTokens(tokens: List) - fun cacheLoadedTokens(tokens: List) - fun cacheBalances(networkId: String, derivationPath: String?, balances: Map) - fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String) - fun getAvailableTokens(networkId: String): List - fun getInWalletTokens(): List - fun getLoadedTokens(): List - fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount? - fun getLastFeeForNetwork(networkId: String): BigDecimal? -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt deleted file mode 100644 index 4379d70d9f..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.tangem.feature.swap.domain.cache - -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.ui.TokenWithBalance -import java.math.BigDecimal - -class SwapDataCacheImpl : SwapDataCache { - - private val availableTokensForNetwork: MutableMap> = mutableMapOf() - private val feesForNetworks: MutableMap = mutableMapOf() - private val tokensBalances: MutableMap> = mutableMapOf() - private val lastInWalletTokens = mutableListOf() - private val lastLoadedTokens = mutableListOf() - - override fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String) { - feesForNetworks[networkId] = fee - } - - override fun cacheInWalletTokens(tokens: List) { - lastInWalletTokens.clear() - lastInWalletTokens.addAll(tokens) - } - - override fun cacheLoadedTokens(tokens: List) { - lastLoadedTokens.clear() - lastLoadedTokens.addAll(tokens) - } - - override fun getInWalletTokens(): List { - return lastInWalletTokens - } - - override fun getLoadedTokens(): List { - return lastLoadedTokens - } - - override fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount? { - return tokensBalances[createKeyFrom(networkId, derivationPath)]?.get(symbol) - } - - override fun cacheBalances(networkId: String, derivationPath: String?, balances: Map) { - tokensBalances[createKeyFrom(networkId, derivationPath)] = balances - } - - override fun cacheAvailableToSwapTokens(networkId: String, tokens: List) { - availableTokensForNetwork[networkId] = tokens - } - - override fun getLastFeeForNetwork(networkId: String): BigDecimal? { - return feesForNetworks[networkId] - } - - override fun getAvailableTokens(networkId: String): List { - return availableTokensForNetwork.getOrElse(networkId) { emptyList() } - } - - private fun createKeyFrom(networkId: String, derivationPath: String?): String { - return "$networkId;$derivationPath" - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/converters/SwapCurrencyConverter.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/converters/SwapCurrencyConverter.kt index d766f5029a..2cf345a0bb 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/converters/SwapCurrencyConverter.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/converters/SwapCurrencyConverter.kt @@ -1,54 +1,29 @@ package com.tangem.feature.swap.domain.converters -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.utils.converter.TwoWayConverter -import com.tangem.lib.crypto.models.Currency as CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.lib.crypto.models.Currency +import com.tangem.utils.converter.Converter -class SwapCurrencyConverter : TwoWayConverter { +class SwapCurrencyConverter : Converter { - override fun convert(value: Currency): CryptoCurrency { + override fun convert(value: CryptoCurrency): Currency { return when (value) { - is Currency.NonNativeToken -> { - CryptoCurrency.NonNativeToken( - id = value.id, - name = value.name, - symbol = value.symbol, - networkId = value.networkId, - contractAddress = value.contractAddress, - decimalCount = value.decimalCount, - ) - } - is Currency.NativeToken -> { - CryptoCurrency.NativeToken( - id = value.id, - name = value.name, - symbol = value.symbol, - networkId = value.networkId, - ) - } - } - } - - override fun convertBack(value: CryptoCurrency): Currency { - return when (value) { - is CryptoCurrency.NonNativeToken -> { + is CryptoCurrency.Token -> { Currency.NonNativeToken( - id = value.id, + id = value.id.value, name = value.name, symbol = value.symbol, - networkId = value.networkId, + networkId = value.network.id.value, contractAddress = value.contractAddress, - decimalCount = value.decimalCount, - logoUrl = "", + decimalCount = value.decimals, ) } - is CryptoCurrency.NativeToken -> { + is CryptoCurrency.Coin -> { Currency.NativeToken( - id = value.id, + id = value.id.value, name = value.name, symbol = value.symbol, - networkId = value.networkId, - logoUrl = "", + networkId = value.network.id.value, ) } } 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 7a94dfb303..f6bf9093c9 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 @@ -1,14 +1,21 @@ package com.tangem.feature.swap.domain.di +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.demo.DemoConfig +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.tokens.GetCardTokensListUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +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.cache.SwapDataCacheImpl -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.lib.crypto.TransactionManager import com.tangem.lib.crypto.UserWalletManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -26,28 +33,35 @@ class SwapDomainModule { swapRepository: SwapRepository, userWalletManager: UserWalletManager, transactionManager: TransactionManager, - currenciesRepository: CurrenciesRepository, - networksRepository: NetworksRepository, - walletFeatureToggles: WalletFeatureToggles, @SwapScope getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + @SwapScope sendTransactionUseCase: SendTransactionUseCase, + quotesRepository: QuotesRepository, + swapTransactionRepository: SwapTransactionRepository, + walletManagersFacade: WalletManagersFacade, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + initialToCurrencyResolver: InitialToCurrencyResolver, ): SwapInteractor { return SwapInteractorImpl( transactionManager = transactionManager, userWalletManager = userWalletManager, repository = swapRepository, - cache = SwapDataCacheImpl(), allowPermissionsHandler = AllowPermissionsHandlerImpl(), - currenciesRepository = currenciesRepository, - networksRepository = networksRepository, - walletFeatureToggles = walletFeatureToggles, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + getMultiCryptoCurrencyStatusUseCase = getCryptoCurrencyStatusUseCase, + sendTransactionUseCase = sendTransactionUseCase, + quotesRepository = quotesRepository, + walletManagersFacade = walletManagersFacade, + dispatcher = coroutineDispatcherProvider, + swapTransactionRepository = swapTransactionRepository, + initialToCurrencyResolver = initialToCurrencyResolver, ) } @Provides @Singleton fun provideBlockchainInteractor(transactionManager: TransactionManager): BlockchainInteractor { - return BlockchainInteractorImpl( + return DefaultBlockchainInteractor( transactionManager = transactionManager, ) } @@ -58,6 +72,72 @@ class SwapDomainModule { fun providesGetSelectedWalletUseCase(walletsStateHolder: WalletsStateHolder): GetSelectedWalletSyncUseCase { return GetSelectedWalletSyncUseCase(walletsStateHolder = walletsStateHolder) } + + @Provides + @Singleton + fun providesGetCryptoCurrencyStatusUseCase( + currenciesRepository: CurrenciesRepository, + quotesRepository: QuotesRepository, + networksRepository: NetworksRepository, + dispatchers: CoroutineDispatcherProvider, + ): GetCryptoCurrencyStatusesSyncUseCase { + return GetCryptoCurrencyStatusesSyncUseCase( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + dispatchers = dispatchers, + ) + } + + @SwapScope + @Provides + @Singleton + fun providesGetCardTokensListUseCase( + currenciesRepository: CurrenciesRepository, + quotesRepository: QuotesRepository, + networksRepository: NetworksRepository, + dispatchers: CoroutineDispatcherProvider, + ): GetCardTokensListUseCase { + return GetCardTokensListUseCase( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + dispatchers = dispatchers, + ) + } + + @SwapScope + @Provides + fun provideDemoCardUseCase(): IsDemoCardUseCase { + return IsDemoCardUseCase(config = DemoConfig()) + } + + @SwapScope + @Provides + @Singleton + fun provideSendTransactionUseCase( + @SwapScope isDemoCardUseCase: IsDemoCardUseCase, + walletManagersFacade: WalletManagersFacade, + cardSdkConfigRepository: CardSdkConfigRepository, + ): SendTransactionUseCase { + return SendTransactionUseCase( + isDemoCardUseCase = isDemoCardUseCase, + cardSdkConfigRepository = cardSdkConfigRepository, + walletManagersFacade = walletManagersFacade, + ) + } + + @Provides + @Singleton + fun provideInitialToCurrencyResolver( + @SwapScope getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + swapTransactionRepository: SwapTransactionRepository, + ): InitialToCurrencyResolver { + return DefaultInitialToCurrencyResolver( + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + swapTransactionRepository = swapTransactionRepository, + ) + } } @Qualifier diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt index fbcbeb51b0..2c3a3e6af6 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt @@ -1,20 +1,36 @@ package com.tangem.feature.swap.domain.models +import java.math.BigDecimal + sealed class DataError { - object NoError : DataError() - data class UnknownError(val message: String) : DataError() - object InsufficientLiquidity : DataError() -} -fun mapErrors(error: String?): DataError { - return if (error == null) { - DataError.NoError - } else { - when (error) { - INSUFFICIENT_LIQUIDITY_ERROR -> DataError.InsufficientLiquidity - else -> DataError.UnknownError(error) - } + abstract val code: Int + + data class BadRequest(override val code: Int) : DataError() + + data class ExchangeProviderNotFoundError(override val code: Int) : DataError() + + data class ExchangeProviderNotActiveError(override val code: Int) : DataError() + + data class ExchangeProviderNotAvailableError(override val code: Int) : DataError() + + data class ExchangeNotPossibleError(override val code: Int) : DataError() + + data class ExchangeTooSmallAmountError(override val code: Int, val amount: SwapAmount) : DataError() + + data class ExchangeNotEnoughAllowanceError(override val code: Int, val currentAllowance: BigDecimal) : DataError() + + data class ExchangeNotEnoughBalanceError(override val code: Int) : DataError() + + data class ExchangeInvalidAddressError(override val code: Int) : DataError() + + data class ExchangeInvalidFromDecimalsError( + override val code: Int, + val receivedFromDecimals: Int, + val expressFromDecimals: Int, + ) : DataError() + + object UnknownError : DataError() { + override val code: Int = -1 } -} - -private const val INSUFFICIENT_LIQUIDITY_ERROR = "insufficient liquidity" \ No newline at end of file +} \ No newline at end of file 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 index 317b0da94f..45a895181e 100644 --- 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 @@ -11,5 +11,5 @@ import com.tangem.feature.swap.domain.models.DataError */ data class AggregatedSwapDataModel( val dataModel: T?, - val error: DataError = DataError.NoError, + val error: DataError = DataError.UnknownError, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt new file mode 100644 index 0000000000..41d7826e07 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.domain.models.domain + +data class ExchangeQuote( + val toAmount: String, + val allowanceContract: String?, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt new file mode 100644 index 0000000000..dc5c5738be --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt @@ -0,0 +1,20 @@ +package com.tangem.feature.swap.domain.models.domain + +data class ExchangeStatusModel( + val providerId: String, + val status: ExchangeStatus? = null, + val txId: String? = null, + val txUrl: String? = null, +) + +enum class ExchangeStatus { + New, + Waiting, + Confirming, + Verifying, + Exchanging, + Failed, + Sending, + Finished, + Refunded, +} \ 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/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt new file mode 100644 index 0000000000..89ab7c6754 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt @@ -0,0 +1,29 @@ +package com.tangem.feature.swap.domain.models.domain + +import com.tangem.feature.swap.domain.models.SwapAmount + +sealed class ExpressTransactionModel { + + abstract val fromAmount: SwapAmount + abstract val toAmount: SwapAmount + abstract val txId: String + abstract val txTo: String + + data class DEX( + override val fromAmount: SwapAmount, + override val toAmount: SwapAmount, + override val txId: String, + override val txTo: String, + val txFrom: String, + val txData: String, + ) : ExpressTransactionModel() + + data class CEX( + override val fromAmount: SwapAmount, + override val toAmount: SwapAmount, + override val txId: String, + override val txTo: String, + val externalTxId: String, + val externalTxUrl: 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/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt new file mode 100644 index 0000000000..b9ed4f4af3 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.domain.models.domain + +data class LeastTokenInfo( + val contractAddress: String, + val network: String, +) \ 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/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt index 7c0f6812fb..bd1d290190 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain.models.domain +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData import com.tangem.feature.swap.domain.models.ui.TxFee @@ -15,7 +16,8 @@ import com.tangem.feature.swap.domain.models.ui.TxFee data class PermissionOptions( val approveData: RequestApproveStateData, val forTokenContractAddress: String, - val fromToken: Currency, + val fromToken: CryptoCurrency, + val spenderAddress: String, val approveType: SwapApproveType, val txFee: TxFee, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt index 8b9f1c963b..53cfae4ded 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt @@ -1,5 +1,7 @@ package com.tangem.feature.swap.domain.models.domain +import com.tangem.feature.swap.domain.models.SwapAmount + /** * Prepared swap config state that contains flags to determine * @@ -7,8 +9,16 @@ package com.tangem.feature.swap.domain.models.domain * @property isBalanceEnough shows is balance of token enough * @property isFeeEnough shows is amount of main coin enough for fee */ +// todo Refactor this state data class PreparedSwapConfigState( val isAllowedToSpend: Boolean, val isBalanceEnough: Boolean, val isFeeEnough: Boolean, -) \ No newline at end of file + val includeFeeInAmount: IncludeFeeInAmount, +) + +sealed class IncludeFeeInAmount { + data class Included(val amountSubtractFee: SwapAmount) : IncludeFeeInAmount() + object Excluded : IncludeFeeInAmount() + object BalanceNotEnough : IncludeFeeInAmount() +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt index 5e5d782666..df74da7af3 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt @@ -9,4 +9,5 @@ import com.tangem.feature.swap.domain.models.SwapAmount */ data class QuoteModel( val toTokenAmount: SwapAmount, + val allowanceContract: String?, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt new file mode 100644 index 0000000000..2db24c66eb --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.domain.models.domain + +data class SavedLastSwappedCryptoCurrency( + val userWalletId: String, + val cryptoCurrencyId: String, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt new file mode 100644 index 0000000000..f2e6a4d139 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt @@ -0,0 +1,19 @@ +package com.tangem.feature.swap.domain.models.domain + +import java.math.BigDecimal + +data class SavedSwapTransactionListModel( + val userWalletId: String, + val fromCryptoCurrencyId: String, + val toCryptoCurrencyId: String, + val transactions: List, +) + +data class SavedSwapTransactionModel( + val txId: String, + val timestamp: Long, + val fromCryptoAmount: BigDecimal, + val toCryptoAmount: BigDecimal, + val provider: SwapProvider, + val status: ExchangeStatusModel? = null, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt index 7c15701329..8b2927968f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt @@ -2,13 +2,7 @@ package com.tangem.feature.swap.domain.models.domain import com.tangem.feature.swap.domain.models.SwapAmount -/** - * Swap transaction model - * - * @property toTokenAmount amount "target" token - * @property transaction info about transaction - */ data class SwapDataModel( val toTokenAmount: SwapAmount, - val transaction: TransactionModel, + val transaction: ExpressTransactionModel, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt new file mode 100644 index 0000000000..76fa9a8238 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -0,0 +1,50 @@ +package com.tangem.feature.swap.domain.models.domain + +import com.tangem.domain.tokens.model.CryptoCurrencyStatus + +/** + * Domain layer representation of SwapPair data network model. + * + * @property from Short information about the token we want to change + * @property to Short information about the token we want to exchange for + * @property providers Exchange providers + */ +data class SwapPairLeast( + val from: LeastTokenInfo, + val to: LeastTokenInfo, + val providers: List, +) + +data class CryptoCurrencySwapInfo( + val currencyStatus: CryptoCurrencyStatus, + val providers: List, +) + +/** + * Provider that could swap given cryptocurrencies + * + * @property providerId provider id + * @property rateTypes supported rate types + */ +data class SwapProvider( + val providerId: String, + val rateTypes: List = emptyList(), + val name: String, + val type: ExchangeProviderType, + val imageLarge: String, +) + +enum class ExchangeProviderType { + DEX, + CEX, +} + +/** + * Rate type. + * + * Current implementation contains only float type, fixed will be supported later. + */ +enum class RateType { + FLOAT, + FIXED, +} \ 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/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt index 5defcf6bf3..01914d161b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt @@ -25,7 +25,7 @@ class AmountFormatter { * @param currency * @return formatted [String] */ - fun formatBigDecimalAmountToUI(amount: BigDecimal, decimals: Int, currency: String): String { + fun formatBigDecimalAmountToUI(amount: BigDecimal, decimals: Int, currency: String? = null): String { return amount.toFormattedCurrencyString(decimals, currency) } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 28809bc066..d031891ba3 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -1,7 +1,9 @@ package com.tangem.feature.swap.domain.models.ui +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.DataError 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 java.math.BigDecimal @@ -17,9 +19,11 @@ sealed interface SwapState { isAllowedToSpend = false, isBalanceEnough = false, isFeeEnough = false, + includeFeeInAmount = IncludeFeeInAmount.Excluded, ), val permissionState: PermissionDataState = PermissionDataState.Empty, - val swapDataModel: SwapStateData? = null, + val swapDataModel: SwapDataModel? = null, + val txFee: TxFeeState, val tangemFee: Double, ) : SwapState @@ -29,7 +33,10 @@ sealed interface SwapState { val zeroAmountEquivalent: String, ) : SwapState - data class SwapError(val error: DataError) : SwapState + data class SwapError( + val fromTokenInfo: TokenSwapInfo, + val error: DataError, + ) : SwapState } sealed class PermissionDataState { @@ -51,32 +58,42 @@ sealed class PermissionDataState { data class TokenSwapInfo( val tokenAmount: SwapAmount, - val coinId: String, - val tokenWalletBalance: String, - val tokenFiatBalance: String, + val amountFiat: BigDecimal, + val cryptoCurrencyStatus: CryptoCurrencyStatus, ) data class RequestApproveStateData( val fee: TxFeeState, val approveData: String, val fromTokenAmount: SwapAmount, + val spenderAddress: String, ) -data class SwapStateData( - val fee: TxFeeState, - val swapModel: SwapDataModel, -) +// data class SwapStateData( +// val fee: TxFeeState, +// val swapModel: SwapDataModel, +// ) -data class TxFeeState( - val normalFee: TxFee, - val priorityFee: TxFee, -) +sealed class TxFeeState { + data class MultipleFeeState( + val normalFee: TxFee, + val priorityFee: TxFee, + ) : TxFeeState() + + data class SingleFeeState( + val fee: TxFee, + ) : TxFeeState() + + object Empty : TxFeeState() +} data class TxFee( val feeValue: BigDecimal, val gasLimit: Int, val feeFiatFormatted: String, val feeCryptoFormatted: String, + val decimals: Int, + val cryptoSymbol: String, val feeType: FeeType, ) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt new file mode 100644 index 0000000000..1d29633fec --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -0,0 +1,21 @@ +package com.tangem.feature.swap.domain.models.ui + +import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo + +data class TokensDataStateExpress( + val fromGroup: CurrenciesGroup, + val toGroup: CurrenciesGroup, +) { + companion object { + val EMPTY = + TokensDataStateExpress( + fromGroup = CurrenciesGroup(emptyList(), emptyList()), + toGroup = CurrenciesGroup(emptyList(), emptyList()), + ) + } +} + +data class CurrenciesGroup( + val available: List, + val unavailable: List, +) \ 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/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt index ca95f626e3..a593888610 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt @@ -6,6 +6,8 @@ sealed class TxState { val fromAmount: String? = null, val toAmount: String? = null, val txAddress: String, + val txExternalUrl: String? = null, + val timestamp: Long, ) : TxState() object UserCancelled : TxState() diff --git a/features/swap/presentation/build.gradle.kts b/features/swap/presentation/build.gradle.kts index 1068c49d28..b2ea5cda48 100644 --- a/features/swap/presentation/build.gradle.kts +++ b/features/swap/presentation/build.gradle.kts @@ -12,13 +12,20 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.core.featuretoggles) + implementation(projects.core.navigation) implementation(projects.core.utils) implementation(projects.core.ui) implementation(projects.common) /** Domain modules **/ + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) /** AndroidX */ implementation(deps.androidx.activity.compose) @@ -28,8 +35,10 @@ dependencies { implementation(deps.androidx.browser) /** Compose */ + implementation(deps.arrow.core) implementation(deps.compose.foundation) implementation(deps.compose.material) + implementation(deps.compose.material3) implementation(deps.compose.ui.tooling) implementation(deps.compose.coil) implementation(deps.compose.constraintLayout) @@ -37,6 +46,7 @@ dependencies { /** Api */ implementation(projects.features.swap.api) + implementation(projects.features.tokendetails.api) /** Domain */ implementation(projects.features.swap.domain) 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 768624019f..b128e88b19 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 @@ -1,6 +1,10 @@ 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 + +private const val SWAP_CATEGORY = "Swap" sealed class SwapEvents( event: String, @@ -13,14 +17,17 @@ sealed class SwapEvents( ) object SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked") - object ChooseTokenScreenOpened : SwapEvents(event = "Choose Token Screen Opened") - data class SearchTokenClicked(val currencySymbol: String?) : SwapEvents( - event = "Searched Token Clicked", + data class ChooseTokenScreenOpened(val availableTokens: Boolean) : SwapEvents( + event = "Choose Token Screen Opened", + params = mapOf("Available tokens" to if (availableTokens) "Yes" else "No"), + ) + + data class ChooseTokenScreenResult(val tokenChosen: Boolean, val token: String? = null) : SwapEvents( + event = "Choose Token Screen Result", params = buildMap { - if (currencySymbol != null) { - put("Token", currencySymbol) - } + put("Token Chosen", if (tokenChosen) "Yes" else "No") + token?.let { put("Token", it) } }, ) @@ -37,8 +44,61 @@ sealed class SwapEvents( ) object ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel") - object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") - object SwapInProgressScreen : SwapEvents(event = "Swap in Progress Screen Opened") -} -private const val SWAP_CATEGORY = "Swap" \ No newline at end of file + object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") + + data class SwapInProgressScreen( + val provider: SwapProvider, + val commission: FeeType, // Market / Fast + val sendToken: String, + val receiveToken: String, + ) : SwapEvents( + event = "Swap in Progress Screen Opened", + params = mapOf( + "Provider" to provider.name, + "Commission" to if (commission == FeeType.NORMAL) "Market" else "Fast", + "Send Token" to sendToken, + "Receive Token" to receiveToken, + ), + ) + + object ProviderClicked : SwapEvents("Provider Clicked") + + data class ProviderChosen(val provider: SwapProvider) : SwapEvents( + event = "Provider Chosen", + params = mapOf("Provider" to provider.name), + ) + + data class ButtonStatus(val token: String) : SwapEvents( + event = "Button - Status", + params = mapOf("Token" to token), + ) + + data class ButtonExplore(val token: String) : SwapEvents( + event = "Button - Explore", + params = mapOf("Token" to token), + ) + + object NoticeNoAvailableTokensToSwap : SwapEvents("Notice - No Available Tokens To Swap") + + data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents( + event = "Notice - Not Enough Fee", + params = mapOf( + "Token" to token, + "Blockchain" to blockchain, + ), + ) + + data class NoticeProviderError( + val token: String, + val provider: SwapProvider, + val errorCode: Int, + ) : SwapEvents( + event = "Notice - Express Error", + params = mapOf( + "Token" to token, + "Provider" to provider.name, + "Error code" to errorCode.toString(), + ), + ) +} \ No newline at end of file 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 cee76edbb1..6f42bbd1d2 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,43 +1,113 @@ package com.tangem.feature.swap.converters import com.tangem.common.Provider -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.NetworkInfo -import com.tangem.feature.swap.domain.models.ui.FoundTokensState -import com.tangem.feature.swap.domain.models.ui.TokenWithBalance -import com.tangem.feature.swap.models.Network +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.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.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenBalanceData -import com.tangem.feature.swap.models.TokenToSelect +import com.tangem.feature.swap.models.TokenToSelectState +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList class TokensDataConverter( private val onSearchEntered: (String) -> Unit, private val onTokenSelected: (String) -> Unit, private val isBalanceHiddenProvider: Provider, -) { + private val appCurrencyProvider: Provider, +) : Converter { - fun convertWithNetwork(value: FoundTokensState, network: NetworkInfo): SwapSelectTokenStateHolder { + 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 return SwapSelectTokenStateHolder( - addedTokens = value.tokensInWallet.map { tokenWithBalanceToTokenToSelect(it) }, - otherTokens = value.loadedTokens.map { tokenWithBalanceToTokenToSelect(it) }, + availableTokens = value.available.map { tokenWithBalanceToTokenToSelect(it, true) } + .toMutableList() + .apply { + if (this.isNotEmpty()) { + this.add(0, availableTitle) + } + } + .toImmutableList(), + unavailableTokens = value.unavailable.map { tokenWithBalanceToTokenToSelect(it, false) } + .toMutableList() + .apply { + if (this.isNotEmpty()) { + this.add(0, unavailableTitle) + } + } + .toImmutableList(), onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, - network = Network(network.name, network.blockchainId), ) } - private fun tokenWithBalanceToTokenToSelect(tokenWithBalance: TokenWithBalance): TokenToSelect { - return TokenToSelect( - id = tokenWithBalance.token.id, - name = tokenWithBalance.token.name, - symbol = tokenWithBalance.token.symbol, - iconUrl = tokenWithBalance.token.logoUrl, - isNative = tokenWithBalance.token is Currency.NativeToken, + private fun tokenWithBalanceToTokenToSelect( + cryptoCurrencySwapInfo: CryptoCurrencySwapInfo, + isAvailable: Boolean, + ): TokenToSelectState { + val cryptoCurrencyStatus = cryptoCurrencySwapInfo.currencyStatus + return TokenToSelectState.TokenToSelect( + id = cryptoCurrencyStatus.currency.id.value, + name = cryptoCurrencyStatus.currency.name, + symbol = cryptoCurrencyStatus.currency.symbol, + available = isAvailable, + tokenIcon = convertIcon(cryptoCurrencyStatus.currency, isAvailable), addedTokenBalanceData = TokenBalanceData( - amount = tokenWithBalance.tokenBalanceData?.amount, - amountEquivalent = tokenWithBalance.tokenBalanceData?.amountEquivalent, + amount = formatCryptoAmount(cryptoCurrencyStatus), + amountEquivalent = formatFiatAmount(cryptoCurrencyStatus, appCurrencyProvider.invoke()), isBalanceHidden = isBalanceHiddenProvider.invoke(), ), ) } + + private fun convertIcon(currency: CryptoCurrency, isAvailable: Boolean): TokenIconState { + return when (currency) { + is CryptoCurrency.Coin -> { + TokenIconState.CoinIcon( + url = currency.iconUrl, + fallbackResId = currency.networkIconResId, + isGrayscale = !isAvailable, + showCustomBadge = currency.isCustom, + ) + } + is CryptoCurrency.Token -> { + val isGrayscale = currency.network.isTestnet + val background = currency.tryGetBackgroundForTokenIcon(isGrayscale) + val tint = getTintForTokenIcon(background) + TokenIconState.TokenIcon( + url = currency.iconUrl, + isGrayscale = !isAvailable, + showCustomBadge = currency.isCustom, + networkBadgeIconResId = currency.networkIconResId, + fallbackTint = tint, + fallbackBackground = background, + ) + } + } + } + + private fun formatCryptoAmount(cryptoCurrencyStatus: CryptoCurrencyStatus): String { + return BigDecimalFormatter.formatCryptoAmount( + cryptoCurrencyStatus.value.amount, + cryptoCurrencyStatus.currency.symbol, + cryptoCurrencyStatus.currency.decimals, + ) + } + + private fun formatFiatAmount(cryptoCurrencyStatus: CryptoCurrencyStatus, appCurrency: AppCurrency): String { + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = cryptoCurrencyStatus.value.fiatAmount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapPresentationModule.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapPresentationModule.kt new file mode 100644 index 0000000000..441abf4a2e --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapPresentationModule.kt @@ -0,0 +1,34 @@ +package com.tangem.feature.swap.di + +import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.feature.swap.domain.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +class SwapPresentationModule { + + @ViewModelScoped + @Provides + fun providesGetCryptoCurrenciesUseCase( + currenciesRepository: CurrenciesRepository, + dispatcherProvider: CoroutineDispatcherProvider, + quotesRepository: QuotesRepository, + networksRepository: NetworksRepository, + ): GetCryptoCurrencyStatusSyncUseCase { + return GetCryptoCurrencyStatusSyncUseCase( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + dispatchers = dispatcherProvider, + ) + } +} \ 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 2e48e108cc..ac6762d7b7 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 @@ -1,27 +1,29 @@ package com.tangem.feature.swap.models +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + data class SwapSelectTokenStateHolder( - val addedTokens: List, - val otherTokens: List, - val network: Network, + val availableTokens: ImmutableList, + val unavailableTokens: ImmutableList, val onSearchEntered: (String) -> Unit, val onTokenSelected: (String) -> Unit, ) -data class TokenToSelect( - val id: String, - val name: String, - val symbol: String, - val iconUrl: String, - val isNative: Boolean, - val available: Boolean = true, - val addedTokenBalanceData: TokenBalanceData? = null, -) +sealed class TokenToSelectState { -data class Network( - val name: String, - val blockchainId: String, -) + data class Title(val title: TextReference) : TokenToSelectState() + + data class TokenToSelect( + val id: String, + val name: String, + val symbol: String, + val tokenIcon: TokenIconState, + val available: Boolean = true, + val addedTokenBalanceData: TokenBalanceData? = null, + ) : TokenToSelectState() +} data class TokenBalanceData( val amount: String?, 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 1fc4583726..e30eff225c 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,24 +1,32 @@ package com.tangem.feature.swap.models +import androidx.annotation.DrawableRes import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.states.Item import com.tangem.core.ui.components.states.SelectableItemsState +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.ui.TxFee +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.models.states.ProviderState data class SwapStateHolder( - val sendCardData: SwapCardData, - val receiveCardData: SwapCardData, + val sendCardData: SwapCardState, + val receiveCardData: SwapCardState, val networkCurrency: String, - val networkId: String, val blockchainId: String, // not the same as networkId, its local id in app - val fee: FeeState = FeeState.Empty, val warnings: List = emptyList(), val alert: SwapWarning.GenericWarning? = null, val updateInProgress: Boolean = false, + val providerState: ProviderState, + val fee: FeeItemState = FeeItemState.Empty, val permissionState: SwapPermissionState = SwapPermissionState.Empty, + val successState: SwapSuccessStateHolder? = null, val selectTokenState: SwapSelectTokenStateHolder? = null, + val bottomSheetConfig: TangemBottomSheetConfig? = null, val swapButton: SwapButton, @@ -29,21 +37,32 @@ data class SwapStateHolder( val onSuccess: (() -> Unit)? = null, val onMaxAmountSelected: (() -> Unit)? = null, val onShowPermissionBottomSheet: () -> Unit = {}, - val onCancelPermissionBottomSheet: () -> Unit = {}, ) -data class SwapCardData( - val type: TransactionCardType, - val amountEquivalent: String?, - val coinId: String?, - val amountTextFieldValue: TextFieldValue?, - val tokenIconUrl: String, - val tokenCurrency: String, - val balance: String, - val isBalanceHidden: Boolean, - val isNotNativeToken: Boolean, - val canSelectAnotherToken: Boolean = false, -) +sealed class SwapCardState { + + data class SwapCardData( + @DrawableRes val networkIconRes: Int?, + val type: TransactionCardType, + val amountEquivalent: String?, + val token: CryptoCurrencyStatus?, + val coinId: String?, + val amountTextFieldValue: TextFieldValue?, + val tokenIconUrl: String?, + val tokenCurrency: String, + val balance: String, + val isBalanceHidden: Boolean, + val isNotNativeToken: Boolean, + val canSelectAnotherToken: Boolean = false, + ) : SwapCardState() + + data class Empty( + val type: TransactionCardType, + val amountEquivalent: String?, + val amountTextFieldValue: TextFieldValue?, + val canSelectAnotherToken: Boolean = false, + ) : SwapCardState() +} data class SwapButton( val enabled: Boolean, @@ -87,8 +106,9 @@ sealed interface TransactionCardType { } sealed interface SwapWarning { - data class PermissionNeeded(val tokenCurrency: String) : SwapWarning + data class PermissionNeeded(val notificationConfig: NotificationConfig) : SwapWarning object InsufficientFunds : SwapWarning + data class NoAvailableTokensToSwap(val notificationConfig: NotificationConfig) : SwapWarning data class GenericWarning( val message: String? = null, val type: GenericWarningType = GenericWarningType.OTHER, @@ -101,7 +121,10 @@ sealed interface SwapWarning { * * @property priceImpact in format = 10 (means 10%) */ - data class HighPriceImpact(val priceImpact: Int) : SwapWarning + data class HighPriceImpact(val priceImpact: Int, val notificationConfig: NotificationConfig) : SwapWarning + data class TooSmallAmountWarning(val notificationConfig: NotificationConfig) : SwapWarning + data class UnableToCoverFeeWarning(val notificationConfig: NotificationConfig) : SwapWarning + data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning } enum class GenericWarningType { diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt index 56dc3525a8..cbe749c6a8 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt @@ -1,7 +1,23 @@ package com.tangem.feature.swap.models +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.extensions.TextReference + data class SwapSuccessStateHolder( - val fromTokenAmount: String, - val toTokenAmount: String, - val onSecondaryButtonClick: () -> Unit, + val timestamp: Long, + val txUrl: String, + val fee: TextReference, + val rate: TextReference, + val showStatusButton: Boolean, + val providerName: TextReference, + val providerType: TextReference, + val providerIcon: String, + val fromTokenAmount: TextReference, + val toTokenAmount: TextReference, + val fromTokenFiatAmount: TextReference, + val toTokenFiatAmount: TextReference, + val fromTokenIconState: TokenIconState?, + val toTokenIconState: TokenIconState?, + val onExploreButtonClick: () -> Unit, + val onStatusButtonClick: () -> Unit, ) \ 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 d250e06df6..48a3d55f55 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 @@ -1,6 +1,5 @@ package com.tangem.feature.swap.models -import com.tangem.core.ui.components.states.Item import com.tangem.feature.swap.domain.models.ui.TxFee data class UiActions( @@ -14,7 +13,12 @@ data class UiActions( val onBackClicked: () -> Unit, val onMaxAmountSelected: () -> Unit, val openPermissionBottomSheet: () -> Unit, - val hidePermissionBottomSheet: () -> Unit, val onChangeApproveType: (ApproveType) -> Unit, - val onSelectItemFee: (Item) -> Unit, + // region new actions + val onRetryClick: () -> Unit, + val onClickFee: () -> Unit, + val onSelectFeeType: (TxFee) -> Unit, + val onProviderClick: (String) -> Unit, + val onProviderSelect: (String) -> Unit, + val onBuyClick: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt new file mode 100644 index 0000000000..fddf5f557e --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt @@ -0,0 +1,11 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.feature.swap.domain.models.ui.FeeType +import kotlinx.collections.immutable.ImmutableList + +data class ChooseFeeBottomSheetConfig( + val selectedFee: FeeType, + val onSelectFeeType: (FeeType) -> Unit, + val feeItems: ImmutableList, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseProviderBottomSheetConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseProviderBottomSheetConfig.kt new file mode 100644 index 0000000000..68c761cd14 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseProviderBottomSheetConfig.kt @@ -0,0 +1,9 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import kotlinx.collections.immutable.ImmutableList + +data class ChooseProviderBottomSheetConfig( + val selectedProviderId: String, + val providers: ImmutableList, +) : TangemBottomSheetConfigContent \ 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 new file mode 100644 index 0000000000..e01a8b8058 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt @@ -0,0 +1,19 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.feature.swap.domain.models.ui.FeeType + +sealed class FeeItemState { + + data class Content( + val feeType: FeeType, + val title: TextReference, + val amountCrypto: String, + val symbolCrypto: String, + val amountFiatFormatted: String, + val isClickable: Boolean, + val onClick: () -> Unit, + ) : FeeItemState() + + object Empty : FeeItemState() +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/GivePermissionBottomSheetConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/GivePermissionBottomSheetConfig.kt new file mode 100644 index 0000000000..9a8bd7a519 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/GivePermissionBottomSheetConfig.kt @@ -0,0 +1,9 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.feature.swap.models.SwapPermissionState + +data class GivePermissionBottomSheetConfig( + val data: SwapPermissionState.ReadyForRequest, + val onCancel: () -> Unit, +) : TangemBottomSheetConfigContent \ No newline at end of file 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 new file mode 100644 index 0000000000..84254a5716 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt @@ -0,0 +1,67 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.extensions.TextReference + +sealed class ProviderState { + + abstract val onProviderClick: ((String) -> Unit)? + abstract val id: String + + data class Empty( + override val id: String = "", + override val onProviderClick: ((String) -> Unit)? = null, + ) : ProviderState() + + data class Loading( + override val id: String = "", + override val onProviderClick: ((String) -> Unit)? = null, + ) : ProviderState() + + data class Content( + override val id: String, + val name: String, + val type: String, + val iconUrl: String, + val subtitle: TextReference, + val selectionType: SelectionType, + val additionalBadge: AdditionalBadge, + val percentLowerThenBest: Float = 0f, + override val onProviderClick: (String) -> Unit, + ) : ProviderState() + + data class Unavailable( + override val id: String, + val name: String, + val type: String, + val iconUrl: String, + val alertText: TextReference, + val selectionType: SelectionType, + override val onProviderClick: ((String) -> Unit)? = null, + ) : ProviderState() + + sealed class AdditionalBadge { + object BestTrade : AdditionalBadge() + object Empty : AdditionalBadge() + object PermissionRequired : AdditionalBadge() + } + + enum class SelectionType { + NONE, CLICK, SELECT + } +} + +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) + } else { + 0 + } + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt index 8562c1e5db..3b16f0df21 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.Crossfade import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.fragment.app.viewModels +import com.tangem.core.navigation.ReduxNavController import com.tangem.core.ui.components.SystemBarsEffect import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeFragment @@ -26,6 +27,9 @@ class SwapFragment : ComposeFragment() { @Inject override lateinit var appThemeModeHolder: AppThemeModeHolder + @Inject + lateinit var reduxNavController: ReduxNavController + private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -35,6 +39,7 @@ class SwapFragment : ComposeFragment() { SwapRouter( fragmentManager = WeakReference(parentFragmentManager), customTabsManager = CustomTabsManager(WeakReference(context)), + reduxNavController = reduxNavController, ), ) } @@ -82,7 +87,5 @@ class SwapFragment : ComposeFragment() { companion object { const val CURRENCY_BUNDLE_KEY = "swap_currency" - const val DERIVATION_PATH = "DERIVATION_STYLE" - const val NETWORK = "NETWORK" } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt index 62ce0901c5..081730868d 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt @@ -3,12 +3,20 @@ package com.tangem.feature.swap.router import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager +import com.tangem.core.navigation.AppScreen +import com.tangem.core.navigation.NavigationAction +import com.tangem.core.navigation.ReduxNavController +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.tokendetails.navigation.TokenDetailsRouter import java.lang.ref.WeakReference internal class SwapRouter( private val fragmentManager: WeakReference, private val customTabsManager: CustomTabsManager, + private val reduxNavController: ReduxNavController, ) { var currentScreen by mutableStateOf(SwapNavScreen.Main) @@ -29,6 +37,18 @@ internal class SwapRouter( fun openUrl(url: String) { customTabsManager.openUrl(url) } + + fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) { + reduxNavController.navigate( + action = NavigationAction.NavigateTo( + screen = AppScreen.WalletDetails, + bundle = bundleOf( + TokenDetailsRouter.USER_WALLET_ID_KEY to userWalletId.stringValue, + TokenDetailsRouter.CRYPTO_CURRENCY_KEY to currency, + ), + ), + ) + } } enum class SwapNavScreen { 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 new file mode 100644 index 0000000000..d0f47a62a2 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt @@ -0,0 +1,148 @@ +package com.tangem.feature.swap.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.rows.SelectorRowItem +import com.tangem.core.ui.extensions.TextReference +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.models.states.ChooseFeeBottomSheetConfig +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.presentation.R +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun ChooseFeeBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet(config) { content: ChooseFeeBottomSheetConfig -> + ChooseFeeBottomSheetContent(content = content) + } +} + +@Composable +private fun ChooseFeeBottomSheetContent(content: ChooseFeeBottomSheetConfig) { + Column( + modifier = Modifier.background(TangemTheme.colors.background.primary), + ) { + Text( + text = stringResource(R.string.common_fee_selector_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing10) + .align(Alignment.CenterHorizontally), + ) + Column( + modifier = Modifier + .padding(TangemTheme.dimens.spacing16) + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) { + FeeItemsBlock(content) + } + Text( + text = stringResource(R.string.common_fee_selector_footer), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier + .padding( + vertical = TangemTheme.dimens.spacing8, + horizontal = TangemTheme.dimens.spacing16, + ) + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Start, + ) + } +} + +@Composable +private fun FeeItemsBlock(content: ChooseFeeBottomSheetConfig) { + content.feeItems.forEach { feeItem -> + val isSelected = feeItem.feeType == content.selectedFee + val preEllipsizeText = feeItem.amountCrypto + val postEllipsizeText = " ${feeItem.symbolCrypto} (${feeItem.amountFiatFormatted})" + when (feeItem.feeType) { + FeeType.NORMAL -> { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_market, + iconRes = R.drawable.ic_bird_24, + preEllipsize = TextReference.Str(preEllipsizeText), + postEllipsize = TextReference.Str(postEllipsizeText), + isSelected = isSelected, + onSelect = { content.onSelectFeeType(feeItem.feeType) }, + ) + } + FeeType.PRIORITY -> { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_fast, + iconRes = R.drawable.ic_hare_24, + preEllipsize = TextReference.Str(preEllipsizeText), + postEllipsize = TextReference.Str(postEllipsizeText), + isSelected = isSelected, + onSelect = { content.onSelectFeeType(feeItem.feeType) }, + ) + } + } + } +} + +@Preview +@Composable +private fun ChooseFeeBottomSheetContent_Preview() { + val feeItems = listOf( + FeeItemState.Content( + feeType = FeeType.NORMAL, + title = stringReference("Fee"), + amountCrypto = "1000", + symbolCrypto = "MATIC", + amountFiatFormatted = "(10$)", + isClickable = false, + onClick = {}, + ), + FeeItemState.Content( + feeType = FeeType.PRIORITY, + title = stringReference("Fee"), + amountCrypto = "2000", + symbolCrypto = "MATIC", + amountFiatFormatted = "(10$)", + isClickable = false, + onClick = {}, + ), + ).toImmutableList() + Column { + TangemTheme(isDark = true) { + ChooseFeeBottomSheetContent( + ChooseFeeBottomSheetConfig( + selectedFee = FeeType.NORMAL, + onSelectFeeType = {}, + feeItems = feeItems, + ), + ) + } + + SpacerH24() + + TangemTheme(isDark = false) { + ChooseFeeBottomSheetContent( + ChooseFeeBottomSheetConfig( + selectedFee = FeeType.NORMAL, + onSelectFeeType = {}, + feeItems = feeItems, + ), + ) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..2dd33c473f --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt @@ -0,0 +1,113 @@ +package com.tangem.feature.swap.ui + +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.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.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +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.ProviderState +import com.tangem.feature.swap.presentation.R +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + contentColor = TangemTheme.colors.background.tertiary, + ) { content: ChooseProviderBottomSheetConfig -> + ChooseProviderBottomSheetContent(content = content) + } +} + +@Composable +private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetConfig) { + Column { + 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), + ) + Text( + text = stringResource(R.string.express_choose_providers_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing10) + .padding(horizontal = TangemTheme.dimens.spacing56) + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center, + ) + Column( + modifier = Modifier + .padding(TangemTheme.dimens.spacing16) + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium), + ) { + content.providers.forEach { provider -> + val isSelected = provider.id == content.selectedProviderId + ProviderItem( + state = provider, + isSelected = isSelected, + modifier = Modifier + .clickable( + enabled = provider.onProviderClick != null, + onClick = { provider.onProviderClick?.invoke(provider.id) }, + ) + .padding(TangemTheme.dimens.spacing12), + ) + } + } + } +} + +@Preview +@Composable +private fun ChooseProviderBottomSheet_Preview() { + val providers = listOf( + ProviderState.Content( + id = "1", + name = "1inch", + type = "DEX", + iconUrl = "", + subtitle = stringReference("1 000 000"), + additionalBadge = ProviderState.AdditionalBadge.BestTrade, + percentLowerThenBest = -1.0f, + selectionType = ProviderState.SelectionType.SELECT, + onProviderClick = {}, + ), + ProviderState.Unavailable( + id = "2", + name = "1inch", + type = "DEX", + iconUrl = "", + selectionType = ProviderState.SelectionType.SELECT, + alertText = stringReference("Unavailable"), + ), + ) + TangemTheme(isDark = false) { + ChooseProviderBottomSheetContent( + ChooseProviderBottomSheetConfig( + selectedProviderId = "1", + providers = providers.toImmutableList(), + ), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f379ae3511 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt @@ -0,0 +1,76 @@ +package com.tangem.feature.swap.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.rows.SimpleActionRow +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.models.states.FeeItemState + +@Composable +fun FeeItemBlock(state: FeeItemState) { + if (state is FeeItemState.Content) { + FeeItem(state = state) + } +} + +@Composable +fun FeeItem(state: FeeItemState.Content) { + Box( + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .clickable( + onClick = state.onClick, + ) + .fillMaxWidth() + .defaultMinSize(minHeight = TangemTheme.dimens.size68), + ) { + 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, + ) + } +} + +@Preview +@Composable +private fun FeeItemPreview() { + val state = FeeItemState.Content( + feeType = FeeType.NORMAL, + title = stringReference("Fee"), + amountCrypto = "1000", + symbolCrypto = "MATIC", + amountFiatFormatted = "(1000$)", + isClickable = false, + onClick = {}, + ) + Column { + TangemTheme(isDark = false) { + FeeItem(state = state) + } + + SpacerH24() + + TangemTheme(isDark = true) { + FeeItem(state = state) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..e5fe4ef2c9 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -0,0 +1,452 @@ +package com.tangem.feature.swap.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator +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.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer +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.ProviderState + +/** + * UI Item for swap provider wrapped in a [BaseContainer] with rounded corners + * + * https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?type=design&node-id=7856-41909&mode=design&t=vo7dyElitnzSPSW3-4 + */ + +private const val GRAY_SCALE_SATURATION = 0f +private const val GRAY_SCALE_ALPHA = 0.4f +private val GrayscaleColorFilter: ColorFilter + get() = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(GRAY_SCALE_SATURATION) }) + +@Composable +fun ProviderItemBlock(state: ProviderState, modifier: Modifier = Modifier) { + if (state !is ProviderState.Empty) { + BaseContainer(modifier = modifier) { + ProviderItem( + state = state, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + } +} + +@Composable +fun ProviderItem(state: ProviderState, modifier: Modifier = Modifier, isSelected: Boolean = false) { + when (state) { + is ProviderState.Content -> { + ProviderContentState( + state = state, + modifier = modifier, + isSelected = isSelected, + ) + } + is ProviderState.Loading -> { + ProviderLoadingState( + modifier = modifier, + ) + } + is ProviderState.Unavailable -> { + ProviderUnavailableState( + state = state, + modifier = modifier, + isSelected = isSelected, + ) + } + is ProviderState.Empty -> { + // do nothing + } + } +} + +@Suppress("LongMethod") +@Composable +private fun ProviderContentState( + state: ProviderState.Content, + modifier: Modifier = Modifier, + isSelected: Boolean = false, +) { + Box(modifier = modifier.fillMaxWidth()) { + Row { + SubcomposeAsyncImage( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .size(size = TangemTheme.dimens.size40) + .clip(TangemTheme.shapes.roundedCorners8), + model = ImageRequest.Builder(context = LocalContext.current) + .data(state.iconUrl) + .crossfade(enable = true) + .allowHardware(false) + .build(), + loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) }, + error = { + ErrorProviderIcon( + Modifier.size( + size = TangemTheme.dimens.size40, + ), + ) + }, + contentDescription = null, + ) + + Column( + modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), + ) { + Row { + AnimatedContent(targetState = state.name, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + ) + } + AnimatedContent(targetState = state.type, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + } + when (state.additionalBadge) { + ProviderState.AdditionalBadge.BestTrade -> + BestTradeItem(Modifier.padding(start = TangemTheme.dimens.spacing4)) + ProviderState.AdditionalBadge.PermissionRequired -> + PermissionBadgeItem(Modifier.padding(start = TangemTheme.dimens.spacing4)) + ProviderState.AdditionalBadge.Empty -> { + // no-op + } + } + } + Row( + modifier = Modifier.padding( + top = TangemTheme.dimens.spacing8, + end = TangemTheme.dimens.spacing56, + ), + ) { + AnimatedContent(targetState = state.subtitle, label = "") { + Text( + text = it.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + if (state.percentLowerThenBest > 0f) { + AnimatedContent(targetState = state.percentLowerThenBest, label = "") { + Text( + text = "-$it%", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.warning, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } + } + } + } + + ProviderChevron(selectionType = state.selectionType, isSelected = isSelected) + } +} + +@Composable +private fun ProviderUnavailableState( + state: ProviderState.Unavailable, + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxWidth()) { + Row { + val (alpha, colorFilter) = GRAY_SCALE_ALPHA to GrayscaleColorFilter + SubcomposeAsyncImage( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .size(size = TangemTheme.dimens.size40) + .clip(TangemTheme.shapes.roundedCorners8), + model = ImageRequest.Builder(context = LocalContext.current) + .data(state.iconUrl) + .crossfade(enable = true) + .allowHardware(false) + .build(), + loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) }, + error = { + ErrorProviderIcon( + Modifier.size( + size = TangemTheme.dimens.size40, + ), + ) + }, + alpha = alpha, + colorFilter = colorFilter, + contentDescription = null, + ) + + Column( + modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), + ) { + Row { + AnimatedContent(targetState = state.name, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + AnimatedContent(targetState = state.type, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + } + } + AnimatedContent(targetState = state.alertText, label = "") { + Text( + text = it.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) + } + } + } + + ProviderChevron(selectionType = state.selectionType, isSelected = isSelected) + } +} + +@Composable +private fun ProviderLoadingState(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxWidth()) { + Column { + Text( + text = "Provider", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), + ) + + Row( + modifier = Modifier.padding( + top = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing12, + ), + ) { + CircularProgressIndicator( + modifier = Modifier.size(TangemTheme.dimens.size16), + color = TangemTheme.colors.icon.informative, + ) + Text( + text = "Fetching best rates ...", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + } + } + + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Composable +private fun BoxScope.ProviderChevron(selectionType: ProviderState.SelectionType, isSelected: Boolean) { + when (selectionType) { + ProviderState.SelectionType.NONE -> { + /* no-op */ + } + ProviderState.SelectionType.CLICK -> { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + tint = TangemTheme.colors.icon.informative, + ) + } + ProviderState.SelectionType.SELECT -> { + if (isSelected) { + Icon( + painter = painterResource(id = R.drawable.ic_check_24), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + tint = TangemTheme.colors.icon.accent, + ) + } + } + } +} + +@Composable +private fun BaseContainer(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { + Box( + modifier = modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .fillMaxWidth() + .defaultMinSize(minHeight = TangemTheme.dimens.size68), + ) { + content() + } +} + +@Composable +private fun ErrorProviderIcon(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background( + color = TangemTheme.colors.background.secondary, + shape = TangemTheme.shapes.roundedCorners8, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.matchParentSize(), + painter = painterResource(id = R.drawable.ic_custom_token_44), + contentDescription = null, + ) + } +} + +@Composable +private fun BestTradeItem(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = TangemTheme.shapes.roundedCornersLarge, + ), + ) { + Text( + text = "Best rate", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6), + ) + } +} + +@Composable +private fun PermissionBadgeItem(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors.background.secondary, + shape = TangemTheme.shapes.roundedCornersLarge, + ), + ) { + Text( + text = "Permission required", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6), + ) + } +} + +@Preview +@Composable +private fun ProviderItem_Loading_Preview() { + Column { + TangemTheme(isDark = false) { + ProviderItemBlock(state = ProviderState.Loading()) + } + + SpacerH24() + + TangemTheme(isDark = true) { + ProviderItemBlock(state = ProviderState.Loading()) + } + } +} + +@Preview +@Composable +private fun ProviderItem_Content_Preview() { + val state = ProviderState.Content( + id = "1", + name = "1inch", + type = "DEX", + iconUrl = "", + subtitle = stringReference("1 000 000"), + additionalBadge = ProviderState.AdditionalBadge.PermissionRequired, + percentLowerThenBest = -1.0f, + selectionType = ProviderState.SelectionType.SELECT, + onProviderClick = {}, + ) + Column { + TangemTheme(isDark = false) { + ProviderItemBlock(state = state) + } + + SpacerH24() + + TangemTheme(isDark = true) { + ProviderItemBlock(state = state) + } + } +} + +@Preview +@Composable +private fun ProviderItem_Unavailable_Preview() { + val state = ProviderState.Unavailable( + id = "1", + name = "1inch", + type = "DEX", + iconUrl = "", + selectionType = ProviderState.SelectionType.SELECT, + alertText = stringReference("Unavailable"), + ) + Column { + TangemTheme(isDark = false) { + ProviderItemBlock(state = state) + } + + SpacerH24() + + TangemTheme(isDark = true) { + ProviderItemBlock(state = state) + } + + SpacerH24() + + TangemTheme(isDark = true) { + ProviderItem(state = state, isSelected = true) + } + } +} \ No newline at end of file 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 75b0e2b05f..a1c6e1e09f 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 @@ -3,62 +3,80 @@ package com.tangem.feature.swap.ui import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.Provider -import com.tangem.core.ui.components.states.Item -import com.tangem.core.ui.components.states.SelectableItemsState -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.notifications.NotificationConfig +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.converters.TokensDataConverter import com.tangem.feature.swap.domain.models.DataError -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.NetworkInfo -import com.tangem.feature.swap.domain.models.domain.isNonNative +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.formatToUIRepresentation import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.* import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.min /** * State builder creates a specific states for SwapScreen */ -@Suppress("LargeClass") -internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: Provider) { +@Suppress("LargeClass", "TooManyFunctions") +internal class StateBuilder( + private val actions: UiActions, + private val isBalanceHiddenProvider: Provider, + private val appCurrencyProvider: Provider, +) { + + private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) private val tokensDataConverter = TokensDataConverter( onSearchEntered = actions.onSearchEntered, onTokenSelected = actions.onTokenSelected, isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, ) - fun createInitialLoadingState(initialCurrency: Currency, networkInfo: NetworkInfo): SwapStateHolder { + fun createInitialLoadingState(initialCurrency: CryptoCurrency, networkInfo: NetworkInfo): SwapStateHolder { return SwapStateHolder( - networkId = initialCurrency.networkId, blockchainId = networkInfo.blockchainId, - sendCardData = SwapCardData( + sendCardData = SwapCardState.SwapCardData( type = TransactionCardType.SendCard(actions.onAmountChanged, actions.onAmountSelected), amountEquivalent = null, amountTextFieldValue = null, - tokenIconUrl = initialCurrency.logoUrl, + token = null, + tokenIconUrl = initialCurrency.iconUrl, tokenCurrency = initialCurrency.symbol, - coinId = initialCurrency.id, + coinId = initialCurrency.network.backendId, canSelectAnotherToken = false, - isNotNativeToken = initialCurrency.isNonNative(), + isNotNativeToken = initialCurrency is CryptoCurrency.Token, balance = "", + networkIconRes = getActiveIconRes(initialCurrency.network.id.value), isBalanceHidden = true, ), - receiveCardData = SwapCardData( + receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountEquivalent = null, tokenIconUrl = "", tokenCurrency = "", + token = null, amountTextFieldValue = null, canSelectAnotherToken = false, balance = "", isNotNativeToken = false, + networkIconRes = null, coinId = null, isBalanceHidden = true, ), - fee = FeeState.Loading, + fee = FeeItemState.Empty, networkCurrency = networkInfo.blockchainCurrency, swapButton = SwapButton(enabled = false, loading = true, onClick = {}), onRefresh = {}, @@ -67,45 +85,105 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: onMaxAmountSelected = actions.onMaxAmountSelected, updateInProgress = true, onShowPermissionBottomSheet = actions.openPermissionBottomSheet, - onCancelPermissionBottomSheet = actions.hidePermissionBottomSheet, + providerState = ProviderState.Empty(), + ) + } + + fun createNoAvailableTokensToSwapState( + uiStateHolder: SwapStateHolder, + fromToken: CryptoCurrencyStatus, + ): SwapStateHolder { + 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", + ), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + token = fromToken, + tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, + coinId = uiStateHolder.sendCardData.coinId, + isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, + tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, + canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, + balance = fromToken.getFormattedAmount(), + networkIconRes = getActiveIconRes(fromToken.currency.network.id.value), + isBalanceHidden = isBalanceHiddenProvider(), + ), + receiveCardData = SwapCardState.Empty( + type = TransactionCardType.ReceiveCard(), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + amountTextFieldValue = TextFieldValue( + text = "0", + ), + canSelectAnotherToken = true, + ), + warnings = listOf( + SwapWarning.NoAvailableTokensToSwap( + notificationConfig = NotificationConfig( + title = resourceReference(R.string.warning_express_no_exchangeable_coins_title), + subtitle = resourceReference( + id = R.string.warning_express_no_exchangeable_coins_description, + formatArgs = wrappedList(fromToken.currency.name), + ), + iconResId = R.drawable.img_attention_20, + ), + ), + ), + fee = FeeItemState.Empty, + swapButton = SwapButton( + enabled = false, + loading = false, + onClick = { }, + ), + updateInProgress = false, ) } fun createQuotesLoadingState( uiStateHolder: SwapStateHolder, - fromToken: Currency, - toToken: Currency, + fromToken: CryptoCurrency, + toToken: CryptoCurrency, mainTokenId: String, ): SwapStateHolder { - val canSelectSendToken = mainTokenId != fromToken.id - val canSelectReceiveToken = mainTokenId != toToken.id + val canSelectSendToken = mainTokenId != fromToken.id.value + val canSelectReceiveToken = mainTokenId != toToken.id.value + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardData( + sendCardData = SwapCardState.SwapCardData( type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = null, - tokenIconUrl = fromToken.logoUrl, + token = uiStateHolder.sendCardData.token, + tokenIconUrl = fromToken.iconUrl, tokenCurrency = fromToken.symbol, - coinId = fromToken.id, - isNotNativeToken = fromToken.isNonNative(), + coinId = fromToken.network.backendId, + isNotNativeToken = fromToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectSendToken, balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "", + networkIconRes = getActiveIconRes(fromToken.network.id.value), isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardData( + receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountTextFieldValue = null, amountEquivalent = null, - tokenIconUrl = toToken.logoUrl, + token = uiStateHolder.receiveCardData.token, + tokenIconUrl = toToken.iconUrl, tokenCurrency = toToken.symbol, - coinId = toToken.id, - isNotNativeToken = toToken.isNonNative(), + coinId = toToken.network.backendId, + isNotNativeToken = toToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectReceiveToken, balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "", + networkIconRes = getActiveIconRes(toToken.network.id.value), isBalanceHidden = isBalanceHiddenProvider(), ), - fee = FeeState.Loading, + warnings = emptyList(), + fee = FeeItemState.Empty, swapButton = SwapButton(enabled = false, loading = true, onClick = {}), + providerState = ProviderState.Loading(), permissionState = uiStateHolder.permissionState, updateInProgress = true, ) @@ -117,53 +195,51 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: * @param uiStateHolder whole screen state * @param quoteModel data model * @param fromToken token data to swap - * @param onFeeSetup callback for reset fee after auto update * @return updated whole screen state */ + @Suppress("LongMethod", "LongParameterList") fun createQuotesLoadedState( uiStateHolder: SwapStateHolder, quoteModel: SwapState.QuotesLoadedState, - fromToken: Currency, - onFeeSetup: (TxFee) -> Unit, + fromToken: CryptoCurrency, + swapProvider: SwapProvider, + bestRatedProviderId: String, + isManyProviders: Boolean, + selectedFeeType: FeeType, ): SwapStateHolder { - val warnings = mutableListOf() - if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && - quoteModel.preparedSwapConfigState.isFeeEnough && - quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest - ) { - warnings.add(SwapWarning.PermissionNeeded(fromToken.symbol)) - } - if (!quoteModel.preparedSwapConfigState.isBalanceEnough) { - warnings.add(SwapWarning.InsufficientFunds) - } - - if (quoteModel.priceImpact > PRICE_IMPACT_THRESHOLD) { - warnings.add(SwapWarning.HighPriceImpact((quoteModel.priceImpact * HUNDRED_PERCENTS).toInt())) - } - val feeState = createFeeState(quoteModel, uiStateHolder, onFeeSetup) + 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 fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus + val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus return uiStateHolder.copy( - sendCardData = SwapCardData( + sendCardData = SwapCardState.SwapCardData( type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, - amountEquivalent = quoteModel.fromTokenInfo.tokenFiatBalance, + amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat), + token = fromCurrencyStatus, tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = quoteModel.fromTokenInfo.coinId, + coinId = fromCurrencyStatus.currency.network.backendId, isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - balance = quoteModel.fromTokenInfo.tokenWalletBalance, + networkIconRes = uiStateHolder.sendCardData.networkIconRes, + balance = fromCurrencyStatus.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardData( + receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountTextFieldValue = TextFieldValue(quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation()), - amountEquivalent = quoteModel.toTokenInfo.tokenFiatBalance, + amountEquivalent = getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat), + token = toCurrencyStatus, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = quoteModel.toTokenInfo.coinId, + coinId = toCurrencyStatus.currency.network.backendId, isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - balance = quoteModel.toTokenInfo.tokenWalletBalance, + networkIconRes = uiStateHolder.receiveCardData.networkIconRes, + balance = toCurrencyStatus.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ), networkCurrency = quoteModel.networkCurrency, @@ -171,59 +247,253 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: permissionState = convertPermissionState( lastPermissionState = uiStateHolder.permissionState, permissionDataState = quoteModel.permissionState, - feeState = feeState, onGivePermissionClick = actions.onGivePermissionClick, onChangeApproveType = actions.onChangeApproveType, ), fee = feeState, swapButton = SwapButton( - enabled = quoteModel.preparedSwapConfigState.isAllowedToSpend && - quoteModel.preparedSwapConfigState.isBalanceEnough && - quoteModel.preparedSwapConfigState.isFeeEnough, + enabled = getSwapButtonEnabled(quoteModel.preparedSwapConfigState), loading = false, onClick = actions.onSwapClick, ), updateInProgress = false, + providerState = swapProvider.convertToContentClickableProviderState( + isBestRate = bestRatedProviderId == swapProvider.providerId, + fromTokenInfo = quoteModel.fromTokenInfo, + toTokenInfo = quoteModel.toTokenInfo, + isNeedBadge = isManyProviders, + selectionType = ProviderState.SelectionType.CLICK, + onProviderClick = actions.onProviderClick, + ), ) } + private fun getWarningsForSuccessState( + quoteModel: SwapState.QuotesLoadedState, + fromToken: CryptoCurrency, + ): List { + val warnings = mutableListOf() + if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && + quoteModel.preparedSwapConfigState.isFeeEnough && + quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest + ) { + warnings.add( + SwapWarning.PermissionNeeded( + createPermissionNotificationConfig(fromToken.symbol), + ), + ) + } + when (quoteModel.preparedSwapConfigState.includeFeeInAmount) { + is IncludeFeeInAmount.Included -> + warnings.add( + SwapWarning.GeneralWarning( + createNetworkFeeCoverageNotificationConfig(), + ), + ) + else -> Unit + } + if (!quoteModel.preparedSwapConfigState.isFeeEnough && + quoteModel.preparedSwapConfigState.isBalanceEnough + ) { + warnings.add( + SwapWarning.UnableToCoverFeeWarning( + createUnableToCoverFeeNotificationConfig( + fromToken = fromToken, + onBuyClick = actions.onBuyClick, + ), + ), + ) + } + // 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 { + return when (preparedSwapConfigState.includeFeeInAmount) { + IncludeFeeInAmount.BalanceNotEnough -> false + IncludeFeeInAmount.Excluded -> + preparedSwapConfigState.isAllowedToSpend && + preparedSwapConfigState.isBalanceEnough && + preparedSwapConfigState.isFeeEnough + is IncludeFeeInAmount.Included -> true + } + } + + fun createQuotesErrorState( + uiStateHolder: SwapStateHolder, + swapProvider: SwapProvider, + fromToken: TokenSwapInfo, + toToken: CryptoCurrencyStatus?, + dataError: DataError, + ): 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 providerState = getProviderStateForError( + swapProvider = swapProvider, + fromToken = fromToken.cryptoCurrencyStatus.currency, + dataError = dataError, + onProviderClick = actions.onProviderClick, + selectionType = ProviderState.SelectionType.CLICK, + ) + val receiveCardData = toToken?.let { + SwapCardState.SwapCardData( + type = TransactionCardType.ReceiveCard(), + amountTextFieldValue = TextFieldValue( + text = "0", + ), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + token = toToken, + tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, + coinId = toToken.currency.network.backendId, + isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, + tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, + canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, + networkIconRes = uiStateHolder.receiveCardData.networkIconRes, + balance = toToken.getFormattedAmount(), + isBalanceHidden = isBalanceHiddenProvider(), + ) + } ?: SwapCardState.Empty( + type = TransactionCardType.ReceiveCard(), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + amountTextFieldValue = TextFieldValue( + text = "0", + ), + canSelectAnotherToken = true, + ) + return uiStateHolder.copy( + sendCardData = uiStateHolder.sendCardData.copy( + amountEquivalent = getFormattedFiatAmount(fromToken.amountFiat), + ), + receiveCardData = receiveCardData, + warnings = listOf(warning), + permissionState = SwapPermissionState.Empty, + fee = FeeItemState.Empty, + swapButton = SwapButton( + enabled = false, + loading = false, + onClick = actions.onSwapClick, + ), + updateInProgress = false, + providerState = providerState, + ) + } + + private fun getProviderStateForError( + swapProvider: SwapProvider, + fromToken: CryptoCurrency, + dataError: DataError, + onProviderClick: (String) -> Unit, + selectionType: ProviderState.SelectionType, + ): ProviderState { + return when (dataError) { + is DataError.ExchangeTooSmallAmountError -> { + swapProvider.convertToAvailableFromProviderState( + alertText = resourceReference( + R.string.express_provider_min_amount, + wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), + ), + selectionType = selectionType, + onProviderClick = onProviderClick, + ) + } + else -> { + ProviderState.Empty() + } + } + } + + private fun getWarningForError(dataError: DataError, fromToken: CryptoCurrency): SwapWarning { + return when (dataError) { + is DataError.ExchangeTooSmallAmountError -> SwapWarning.TooSmallAmountWarning( + notificationConfig = NotificationConfig( + title = resourceReference( + id = R.string.warning_express_too_minimal_amount_title, + formatArgs = wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), + ), + subtitle = resourceReference(R.string.warning_express_too_minimal_amount_description), + iconResId = R.drawable.ic_alert_circle_24, + ), + ) + is DataError.UnknownError -> SwapWarning.GeneralWarning( + notificationConfig = NotificationConfig( + title = resourceReference(R.string.common_error), + subtitle = resourceReference(R.string.swapping_generic_error), + iconResId = R.drawable.img_attention_20, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_button_refresh), + onClick = actions.onRetryClick, + ), + ), + ) + 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, ): SwapStateHolder { + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardData( + sendCardData = SwapCardState.SwapCardData( type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = emptyAmountState.zeroAmountEquivalent, + token = uiStateHolder.sendCardData.token, tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, coinId = uiStateHolder.sendCardData.coinId, isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, + networkIconRes = uiStateHolder.sendCardData.networkIconRes, balance = emptyAmountState.fromTokenWalletBalance, isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardData( + receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountTextFieldValue = TextFieldValue("0"), amountEquivalent = emptyAmountState.zeroAmountEquivalent, + token = uiStateHolder.receiveCardData.token, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, coinId = uiStateHolder.receiveCardData.coinId, isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, + networkIconRes = uiStateHolder.receiveCardData.networkIconRes, balance = emptyAmountState.toTokenWalletBalance, isBalanceHidden = isBalanceHiddenProvider(), ), warnings = emptyList(), - fee = FeeState.Empty, + fee = FeeItemState.Empty, swapButton = SwapButton( enabled = false, loading = false, onClick = { }, ), updateInProgress = false, + providerState = ProviderState.Empty(), ) } @@ -236,15 +506,10 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: ) } - fun addTokensToState( - uiState: SwapStateHolder, - dataState: FoundTokensState, - networkInfo: NetworkInfo, - ): SwapStateHolder { + fun addTokensToState(uiState: SwapStateHolder, tokensDataState: CurrenciesGroup): SwapStateHolder { return uiState.copy( - selectTokenState = tokensDataConverter.convertWithNetwork( - value = dataState, - network = networkInfo, + selectTokenState = tokensDataConverter.convert( + value = tokensDataState, ), ) } @@ -256,6 +521,7 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } fun updateSwapAmount(uiState: SwapStateHolder, amount: String): SwapStateHolder { + if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState return uiState.copy( sendCardData = uiState.sendCardData.copy( amountTextFieldValue = TextFieldValue( @@ -267,6 +533,8 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder { + if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState + if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState val patchedSendCardData = uiState.sendCardData.copy( isBalanceHidden = isBalanceHidden, ) @@ -274,11 +542,18 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: isBalanceHidden = isBalanceHidden, ) val selectTokenState = uiState.selectTokenState?.copy( - addedTokens = uiState.selectTokenState.addedTokens.map { - it.copy( - addedTokenBalanceData = it.addedTokenBalanceData?.copy(isBalanceHidden = isBalanceHidden), - ) - }, + availableTokens = uiState.selectTokenState.availableTokens.map { + when (it) { + is TokenToSelectState.TokenToSelect -> { + it.copy( + addedTokenBalanceData = it.addedTokenBalanceData?.copy(isBalanceHidden = isBalanceHidden), + ) + } + is TokenToSelectState.Title -> { + it + } + } + }.toImmutableList(), ) return uiState.copy( @@ -289,10 +564,13 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } fun updateApproveType(uiState: SwapStateHolder, approveType: ApproveType): SwapStateHolder { - return if (uiState.permissionState is SwapPermissionState.ReadyForRequest) { + val config = uiState.bottomSheetConfig?.content as? GivePermissionBottomSheetConfig + return if (config != null) { uiState.copy( - permissionState = uiState.permissionState.copy( - approveType = approveType, + bottomSheetConfig = uiState.bottomSheetConfig.copy( + content = config.copy( + data = config.data.copy(approveType = approveType), + ), ), ) } else { @@ -300,144 +578,54 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } } - fun updateFeeSelectedItem(uiState: SwapStateHolder, item: Item, isFeeEnough: Boolean): SwapStateHolder { - val newSelectedItem = item.copy( - startText = TextReference.Res(R.string.send_network_fee_title), - ) - val permissionState = uiState.permissionState - val newPermissionState = if (permissionState is SwapPermissionState.ReadyForRequest) { - permissionState.copy( - fee = newSelectedItem.endText, - ) - } else { - permissionState - } - val updateState = when (val fee = uiState.fee) { - is FeeState.Loaded -> { - getUpdatedFeeStateForEnoughFee(uiState, fee, item, newSelectedItem, newPermissionState, isFeeEnough) - } - is FeeState.NotEnoughFundsWarning -> { - getUpdatedFeeStateForNotEnoughFee(uiState, fee, item, newSelectedItem, newPermissionState, isFeeEnough) - } - else -> uiState - } - return if (isFeeEnough) { - updateState.copy( - warnings = uiState.warnings.filterNot { it is SwapWarning.InsufficientFunds }, - ) - } else { - updateState.copy( - warnings = uiState.warnings.plus(SwapWarning.InsufficientFunds), - ) - } - } - - @Suppress("LongParameterList") - private fun getUpdatedFeeStateForEnoughFee( - uiState: SwapStateHolder, - fee: FeeState.Loaded, - itemToSelect: Item, - newSelectedItem: Item, - newPermissionState: SwapPermissionState, - isFeeEnough: Boolean, - ): SwapStateHolder { - val newState = fee.state?.copy( - selectedItem = newSelectedItem, - items = selectNewItem(fee.state.items, itemToSelect), - ) - val newFeeState = if (isFeeEnough) { - fee.copy(state = newState) - } else { - FeeState.NotEnoughFundsWarning( - tangemFee = fee.tangemFee, - state = newState, - onSelectItem = fee.onSelectItem, - ) - } + fun createInitialErrorState(uiState: SwapStateHolder, onRefreshClick: () -> Unit): SwapStateHolder { return uiState.copy( - fee = newFeeState, - permissionState = newPermissionState, - swapButton = uiState.swapButton.copy( - enabled = isFeeEnough, + 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, + buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = TextReference.Res(R.string.warning_button_refresh), + onClick = onRefreshClick, + ), + ), + ), ), ) } - @Suppress("LongParameterList") - private fun getUpdatedFeeStateForNotEnoughFee( - uiState: SwapStateHolder, - fee: FeeState.NotEnoughFundsWarning, - itemToSelect: Item, - newSelectedItem: Item, - newPermissionState: SwapPermissionState, - isFeeEnough: Boolean, - ): SwapStateHolder { - val newState = fee.state?.copy( - selectedItem = newSelectedItem, - items = selectNewItem(fee.state.items, itemToSelect), - ) - val newFeeState = if (isFeeEnough) { - FeeState.Loaded( - tangemFee = fee.tangemFee, - state = newState, - onSelectItem = fee.onSelectItem, - ) - } else { - fee.copy(state = newState) - } - return uiState.copy( - fee = newFeeState, - permissionState = newPermissionState, - swapButton = uiState.swapButton.copy( - enabled = isFeeEnough, - ), - ) - } - - private fun createFeeState( - quoteModel: SwapState.QuotesLoadedState, - uiStateHolder: SwapStateHolder, - onFeeSetup: (TxFee) -> Unit, - ): FeeState { - val previousFeeState = when (val stateFee = uiStateHolder.fee) { - is FeeState.Loaded -> stateFee.state - is FeeState.NotEnoughFundsWarning -> stateFee.state - else -> null - } - val permissionState = quoteModel.permissionState - val feeState = if (permissionState is PermissionDataState.PermissionReadyForRequest) { - permissionState.requestApproveData.fee - } else { - quoteModel.swapDataModel?.fee - } - val selectFeeState = createSelectFeeState( - fee = feeState, - previousState = previousFeeState, - onFeeSetup = onFeeSetup, - ) - return if (quoteModel.preparedSwapConfigState.isFeeEnough) { - FeeState.Loaded( - tangemFee = quoteModel.tangemFee, - state = selectFeeState, - onSelectItem = actions.onSelectItemFee, - ) - } else { - FeeState.NotEnoughFundsWarning( - tangemFee = quoteModel.tangemFee, - state = selectFeeState, - onSelectItem = actions.onSelectItemFee, - ) - } - } - - private fun selectNewItem(items: ImmutableList>, selectItem: Item): ImmutableList> { - return items.map { - if (it.id == selectItem.id) { - it.copy(isSelected = true) - } else { - it.copy(isSelected = false) + private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState { + val isClickable: Boolean + val fee = when (txFeeState) { + TxFeeState.Empty -> return FeeItemState.Empty + is TxFeeState.SingleFeeState -> { + isClickable = false + txFeeState.fee } - }.toImmutableList() + is TxFeeState.MultipleFeeState -> { + isClickable = true + when (feeType) { + FeeType.NORMAL -> { + txFeeState.normalFee + } + FeeType.PRIORITY -> { + txFeeState.priorityFee + } + } + } + } + + return FeeItemState.Content( + feeType = feeType, + title = resourceReference(R.string.common_fee_label), + amountCrypto = fee.feeCryptoFormatted, + symbolCrypto = fee.cryptoSymbol, + amountFiatFormatted = fee.feeFiatFormatted, + isClickable = isClickable, + onClick = actions.onClickFee, + ) } fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder { @@ -447,16 +635,43 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: ) } + @Suppress("LongParameterList") fun createSuccessState( uiState: SwapStateHolder, txState: TxState.TxSent, - onSecondaryBtnClick: () -> Unit, + fromAmount: BigDecimal, + toAmount: BigDecimal, + txUrl: String, + onExploreClick: () -> Unit, + onStatusClick: () -> Unit, ): SwapStateHolder { + val providerState = uiState.providerState as ProviderState.Content + val fromToken = requireNotNull((uiState.sendCardData as? SwapCardState.SwapCardData)?.token) + val toToken = requireNotNull((uiState.receiveCardData as? SwapCardState.SwapCardData)?.token) + val fromTokenIconState = iconStateConverter.convert(fromToken) + val toTokenIconState = iconStateConverter.convert(toToken) + val fee = uiState.fee as? FeeItemState.Content ?: return uiState + val fromFiatAmount = getFormattedFiatAmount(fromToken.value.fiatRate?.multiply(fromAmount)) + val toFiatAmount = getFormattedFiatAmount(toToken.value.fiatRate?.multiply(toAmount)) + return uiState.copy( successState = SwapSuccessStateHolder( - fromTokenAmount = txState.fromAmount ?: "", - toTokenAmount = txState.toAmount ?: "", - onSecondaryButtonClick = onSecondaryBtnClick, + timestamp = txState.timestamp, + txUrl = txUrl, + providerName = TextReference.Str(providerState.name), + providerType = TextReference.Str(providerState.type), + showStatusButton = providerState.type == ExchangeProviderType.CEX.name, + providerIcon = providerState.iconUrl, + fee = TextReference.Str("${fee.amountCrypto} ${fee.symbolCrypto} (${fee.amountFiatFormatted})"), + rate = providerState.subtitle, + fromTokenAmount = TextReference.Str(txState.fromAmount.orEmpty()), + toTokenAmount = TextReference.Str(txState.toAmount.orEmpty()), + fromTokenFiatAmount = TextReference.Str(fromFiatAmount), + toTokenFiatAmount = TextReference.Str(toFiatAmount), + fromTokenIconState = fromTokenIconState, + toTokenIconState = toTokenIconState, + onExploreButtonClick = onExploreClick, + onStatusButtonClick = onStatusClick, ), ) } @@ -472,16 +687,6 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: ) } - fun mapError(uiState: SwapStateHolder, error: DataError, onClick: () -> Unit): SwapStateHolder { - return when (error) { - // todo use if needed later - // DataError.InsufficientLiquidity -> TODO() - // DataError.NoError -> TODO() - is DataError.UnknownError -> addWarning(uiState, error.message, true, onClick) - else -> addWarning(uiState, null, false) {} - } - } - fun addAlert(uiState: SwapStateHolder, onClick: () -> Unit): SwapStateHolder { return uiState.copy( alert = SwapWarning.GenericWarning( @@ -515,7 +720,6 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: private fun convertPermissionState( lastPermissionState: SwapPermissionState, permissionDataState: PermissionDataState, - feeState: FeeState, onGivePermissionClick: () -> Unit, onChangeApproveType: (ApproveType) -> Unit, ): SwapPermissionState { @@ -524,100 +728,276 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } else { ApproveType.UNLIMITED } - val fee = when (feeState) { - is FeeSelectState -> feeState.state?.selectedItem?.endText - else -> null - } return when (permissionDataState) { PermissionDataState.Empty -> SwapPermissionState.Empty PermissionDataState.PermissionFailed -> SwapPermissionState.Empty PermissionDataState.PermissionLoading -> SwapPermissionState.InProgress - is PermissionDataState.PermissionReadyForRequest -> SwapPermissionState.ReadyForRequest( - currency = permissionDataState.currency, - amount = permissionDataState.amount, - approveType = approveType, - walletAddress = getShortAddressValue(permissionDataState.walletAddress), - spenderAddress = getShortAddressValue(permissionDataState.spenderAddress), - fee = fee ?: TextReference.Str(""), - approveButton = ApprovePermissionButton( - enabled = true, - onClick = onGivePermissionClick, + is PermissionDataState.PermissionReadyForRequest -> { + val permissionFee = when (val fee = permissionDataState.requestApproveData.fee) { + TxFeeState.Empty -> error("Fee shouldn't be empty") + is TxFeeState.MultipleFeeState -> fee.priorityFee + is TxFeeState.SingleFeeState -> fee.fee + } + SwapPermissionState.ReadyForRequest( + currency = permissionDataState.currency, + amount = permissionDataState.amount, + approveType = approveType, + walletAddress = getShortAddressValue(permissionDataState.walletAddress), + spenderAddress = getShortAddressValue(permissionDataState.spenderAddress), + fee = TextReference.Str("${permissionFee.feeCryptoFormatted} (${permissionFee.feeFiatFormatted})"), + approveButton = ApprovePermissionButton( + enabled = true, + onClick = onGivePermissionClick, + ), + cancelButton = CancelPermissionButton( + enabled = true, + ), + onChangeApproveType = onChangeApproveType, + ) + } + } + } + + fun showPermissionBottomSheet(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder { + val permissionState = uiState.permissionState + if (permissionState is SwapPermissionState.ReadyForRequest) { + val config = GivePermissionBottomSheetConfig( + data = permissionState, + onCancel = onDismiss, + ) + return uiState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = config, ), - cancelButton = CancelPermissionButton( - enabled = true, + ) + } + return uiState + } + + fun dismissBottomSheet(uiState: SwapStateHolder): SwapStateHolder { + return uiState.copy( + bottomSheetConfig = uiState.bottomSheetConfig?.copy(isShow = false), + ) + } + + @Suppress("LongParameterList") + fun showSelectProviderBottomSheet( + uiState: SwapStateHolder, + selectedProviderId: String, + pricesLowerBest: Map, + providersStates: Map, + unavailableProviders: List, + onDismiss: () -> Unit, + ): SwapStateHolder { + val availableProvidersStates = providersStates.entries + .mapNotNull { + it.convertToProviderBottomSheetState(pricesLowerBest, actions.onProviderSelect) + } + .sortedWith(ProviderPercentDiffComparator) + val unavailableProviderStates = unavailableProviders.map { + it.convertToUnavailableProviderState( + alertText = resourceReference(R.string.express_provider_not_available), + selectionType = ProviderState.SelectionType.NONE, + ) + } + val config = ChooseProviderBottomSheetConfig( + selectedProviderId = selectedProviderId, + providers = (availableProvidersStates + unavailableProviderStates).toImmutableList(), + ) + return uiState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = config, + ), + ) + } + + fun updateProvidersBottomSheetContent( + uiState: SwapStateHolder, + tokenSwapInfoForProviders: Map, + ): SwapStateHolder { + val config = uiState.bottomSheetConfig?.content as? ChooseProviderBottomSheetConfig + return if (config != null) { + val providers = config.providers + uiState.copy( + bottomSheetConfig = uiState.bottomSheetConfig.copy( + content = config.copy( + providers = providers.map { + val tokenInfo = tokenSwapInfoForProviders[it.id] + if (it is ProviderState.Content && tokenInfo != null) { + val rateString = tokenInfo.tokenAmount + .getFormattedCryptoAmount(tokenInfo.cryptoCurrencyStatus.currency) + it.copy( + subtitle = stringReference(rateString), + ) + } else { + it + } + }.toImmutableList(), + ), ), - onChangeApproveType = onChangeApproveType, + ) + } else { + uiState + } + } + + fun updateSelectedProvider(uiState: SwapStateHolder, selectedProviderId: String): SwapStateHolder { + val config = uiState.bottomSheetConfig?.content as? ChooseProviderBottomSheetConfig + return if (config != null) { + uiState.copy( + bottomSheetConfig = uiState.bottomSheetConfig.copy( + content = config.copy( + selectedProviderId = selectedProviderId, + ), + ), + ) + } else { + uiState + } + } + + fun showSelectFeeBottomSheet( + uiState: SwapStateHolder, + selectedFee: FeeType, + txFeeState: TxFeeState.MultipleFeeState, + onDismiss: () -> Unit, + ): SwapStateHolder { + val config = ChooseFeeBottomSheetConfig( + selectedFee = selectedFee, + onSelectFeeType = { + val selectedItem = when (it) { + FeeType.NORMAL -> txFeeState.normalFee + FeeType.PRIORITY -> txFeeState.priorityFee + } + actions.onSelectFeeType.invoke(selectedItem) + }, + feeItems = txFeeState.toFeeItemState(), + ) + return uiState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = config, + ), + ) + } + + fun updateSelectedFeeBottomSheet(uiState: SwapStateHolder, selectedFee: FeeType): SwapStateHolder { + val config = uiState.bottomSheetConfig?.content as? ChooseFeeBottomSheetConfig + return if (config != null) { + uiState.copy( + bottomSheetConfig = uiState.bottomSheetConfig.copy( + content = config.copy( + selectedFee = selectedFee, + ), + ), + ) + } else { + uiState + } + } + + private fun TxFeeState.MultipleFeeState.toFeeItemState(): ImmutableList { + return listOf( + FeeItemState.Content( + feeType = this.normalFee.feeType, + title = resourceReference(R.string.common_fee_label), + amountCrypto = this.normalFee.feeCryptoFormatted, + symbolCrypto = this.normalFee.cryptoSymbol, + amountFiatFormatted = this.normalFee.feeFiatFormatted, + isClickable = true, + onClick = {}, + ), + FeeItemState.Content( + feeType = this.priorityFee.feeType, + title = resourceReference(R.string.common_fee_label), + amountCrypto = this.priorityFee.feeCryptoFormatted, + symbolCrypto = this.priorityFee.cryptoSymbol, + amountFiatFormatted = this.priorityFee.feeFiatFormatted, + isClickable = true, + onClick = {}, + ), + ).toImmutableList() + } + + private fun Map.Entry.convertToProviderBottomSheetState( + pricesLowerBest: Map, + onProviderSelect: (String) -> Unit, + ): ProviderState? { + val provider = this.key + return when (val state = this.value) { + is SwapState.EmptyAmountState -> null + is SwapState.QuotesLoadedState -> provider.convertToContentSelectableProviderState( + isBestRate = false, // not show best rate in bottom sheet + state = state, + onProviderClick = onProviderSelect, + pricesLowerBest = pricesLowerBest, + selectionType = ProviderState.SelectionType.SELECT, + ) + is SwapState.SwapError -> getProviderStateForError( + swapProvider = provider, + fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency, + dataError = state.error, + onProviderClick = onProviderSelect, + selectionType = ProviderState.SelectionType.SELECT, ) } } - private fun createSelectFeeState( - fee: TxFeeState?, - previousState: SelectableItemsState?, - onFeeSetup: (TxFee) -> Unit, - ): SelectableItemsState? { - if (fee == null) return null - if (previousState == null) { - onFeeSetup.invoke(fee.normalFee) // if there is no previous state, setup normal fee by default - val selectedItemId = 0 - // by default preselect normal - val preselectedItem = Item( - id = selectedItemId, - startText = TextReference.Res(R.string.send_network_fee_title), - endText = TextReference.Str(fee.normalFee.feeCryptoFormatted + fee.normalFee.feeFiatFormatted), - isSelected = true, - data = fee.normalFee, - ) - val feeItems = mutableListOf>() - val normalFeeItem = Item( - id = selectedItemId, - startText = TextReference.Res(R.string.send_fee_picker_normal), - endText = TextReference.Str(fee.normalFee.feeCryptoFormatted + fee.normalFee.feeFiatFormatted), - isSelected = true, - data = fee.normalFee, - ) - val priorityFeeItem = Item( - id = 1, - startText = TextReference.Res(R.string.send_fee_picker_priority), - endText = TextReference.Str(fee.priorityFee.feeCryptoFormatted + fee.priorityFee.feeFiatFormatted), - isSelected = false, - data = fee.priorityFee, - ) - feeItems.add(normalFeeItem) - feeItems.add(priorityFeeItem) - return SelectableItemsState( - selectedItem = preselectedItem, - items = feeItems.toImmutableList(), - ) - } else { - val normalFeeItem = - requireNotNull(previousState.items.firstOrNull()) { "in previousState there are 2 items" } - .copy( - endText = TextReference.Str(fee.normalFee.feeCryptoFormatted + fee.normalFee.feeFiatFormatted), - ) - val priorityFeeItem = - requireNotNull(previousState.items.getOrNull(1)) { "in previousState there are 2 items" } - .copy( - endText = TextReference.Str( - fee.priorityFee.feeCryptoFormatted + fee.priorityFee.feeFiatFormatted, - ), - ) - val selectedEndText = if (normalFeeItem.isSelected) { - onFeeSetup.invoke(fee.normalFee) - normalFeeItem.endText - } else { - onFeeSetup.invoke(fee.priorityFee) - priorityFeeItem.endText - } - return previousState.copy( - selectedItem = previousState.selectedItem.copy( - endText = selectedEndText, - ), - items = listOf(normalFeeItem, priorityFeeItem).toImmutableList(), - ) - } + // region warnings + private fun createPermissionNotificationConfig(fromTokenSymbol: String): NotificationConfig { + return NotificationConfig( + title = resourceReference(R.string.swapping_permission_header), + subtitle = resourceReference( + id = R.string.swapping_permission_subheader, + formatArgs = wrappedList(fromTokenSymbol), + ), + iconResId = R.drawable.ic_locked_24, + ) } + private fun highPriceImpactNotificationConfig(): NotificationConfig { + return NotificationConfig( + title = resourceReference(R.string.swapping_high_price_impact), + subtitle = resourceReference(R.string.swapping_high_price_impact_description), + iconResId = R.drawable.ic_alert_circle_24, + ) + } + + private fun createUnableToCoverFeeNotificationConfig( + fromToken: CryptoCurrency, + onBuyClick: () -> Unit, + ): NotificationConfig { + return NotificationConfig( + title = resourceReference( + R.string.warning_express_not_enough_fee_for_token_tx_title, + wrappedList(fromToken.network.name), + ), + subtitle = resourceReference( + R.string.warning_express_not_enough_fee_for_token_tx_description, + wrappedList(fromToken.network.name, fromToken.network.currencySymbol), + ), + iconResId = fromToken.networkIconResId, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.common_buy_currency, wrappedList(fromToken.network.currencySymbol)), + onClick = onBuyClick, + ), + ) + } + + private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig { + return NotificationConfig( + title = resourceReference(R.string.send_network_fee_warning_title), + subtitle = resourceReference(R.string.send_network_fee_warning_content), + iconResId = R.drawable.img_attention_20, + ) + } + // end region + private fun getShortAddressValue(fullAddress: String): String { check(fullAddress.length > ADDRESS_MIN_LENGTH) { "Invalid address" } val firstAddressPart = fullAddress.substring(startIndex = 0, endIndex = ADDRESS_FIRST_PART_LENGTH) @@ -628,11 +1008,139 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: return "$firstAddressPart...$secondAddressPart" } + @Suppress("LongParameterList") + private fun SwapProvider.convertToContentClickableProviderState( + isBestRate: Boolean, + fromTokenInfo: TokenSwapInfo, + toTokenInfo: TokenSwapInfo, + selectionType: ProviderState.SelectionType, + isNeedBadge: Boolean, + onProviderClick: (String) -> Unit, + ): ProviderState { + val rate = toTokenInfo.tokenAmount.value.calculateRate( + fromTokenInfo.tokenAmount.value, + toTokenInfo.cryptoCurrencyStatus.currency.decimals, + ) + val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol + val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol + val rateString = "1 $fromCurrencySymbol ≈ $rate $toCurrencySymbol" + val badge = if (isNeedBadge && isBestRate) { + ProviderState.AdditionalBadge.BestTrade + } else { + ProviderState.AdditionalBadge.Empty + } + return ProviderState.Content( + id = this.providerId, + name = this.name, + iconUrl = this.imageLarge, + type = this.type.toString(), + subtitle = stringReference(rateString), + additionalBadge = badge, + selectionType = selectionType, + percentLowerThenBest = ZERO_PERCENT, + onProviderClick = onProviderClick, + ) + } + + private fun SwapProvider.convertToContentSelectableProviderState( + isBestRate: Boolean, + state: SwapState.QuotesLoadedState, + selectionType: ProviderState.SelectionType, + pricesLowerBest: Map, + onProviderClick: (String) -> Unit, + ): ProviderState { + val toTokenInfo = state.toTokenInfo + val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency) + val additionalBadge = if (state.permissionState is PermissionDataState.PermissionReadyForRequest) { + ProviderState.AdditionalBadge.PermissionRequired + } else if (isBestRate) { + ProviderState.AdditionalBadge.BestTrade + } else { + ProviderState.AdditionalBadge.Empty + } + return ProviderState.Content( + id = this.providerId, + name = this.name, + iconUrl = this.imageLarge, + type = this.type.toString(), + subtitle = stringReference(rateString), + additionalBadge = additionalBadge, + selectionType = selectionType, + percentLowerThenBest = pricesLowerBest[this] ?: ZERO_PERCENT, + onProviderClick = onProviderClick, + ) + } + + private fun SwapProvider.convertToUnavailableProviderState( + alertText: TextReference, + selectionType: ProviderState.SelectionType, + onProviderClick: ((String) -> Unit)? = null, + ): ProviderState { + return ProviderState.Unavailable( + id = this.providerId, + name = this.name, + iconUrl = this.imageLarge, + type = this.type.toString(), + selectionType = selectionType, + alertText = alertText, + onProviderClick = onProviderClick, + ) + } + + private fun SwapProvider.convertToAvailableFromProviderState( + alertText: TextReference, + selectionType: ProviderState.SelectionType, + onProviderClick: (String) -> Unit, + ): ProviderState { + return ProviderState.Content( + id = this.providerId, + name = this.name, + iconUrl = this.imageLarge, + type = this.type.toString(), + selectionType = selectionType, + subtitle = alertText, + additionalBadge = ProviderState.AdditionalBadge.Empty, + percentLowerThenBest = ZERO_PERCENT, + onProviderClick = onProviderClick, + ) + } + + private fun CryptoCurrencyStatus.getFormattedAmount(): String { + val amount = value.amount ?: return UNKNOWN_AMOUNT_SIGN + + return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals) + } + + @Suppress("UnusedPrivateMember") + private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String { + val fiatAmount = value.fiatAmount ?: return UNKNOWN_AMOUNT_SIGN + val appCurrency = appCurrencyProvider() + + return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol) + } + + private fun getFormattedFiatAmount(amount: BigDecimal?): String { + val appCurrency = appCurrencyProvider() + + return BigDecimalFormatter.formatFiatAmount(amount, appCurrency.code, appCurrency.symbol) + } + + private fun SwapAmount.getFormattedCryptoAmount(token: CryptoCurrency): String { + return "${this.formatToUIRepresentation()} ${token.network.currencySymbol}" + } + + private fun BigDecimal.calculateRate(to: BigDecimal, decimals: Int): BigDecimal { + return this.divide(to, min(decimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP) + } + 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/SwapPermissionBottomSheetContent.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheet.kt similarity index 87% rename from features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheetContent.kt rename to features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheet.kt index 6f8bee6fe7..fdf6c22c6d 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheetContent.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheet.kt @@ -12,7 +12,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.* -import com.tangem.core.ui.components.atoms.Hand +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @@ -20,12 +21,21 @@ import com.tangem.feature.swap.models.ApprovePermissionButton import com.tangem.feature.swap.models.ApproveType import com.tangem.feature.swap.models.CancelPermissionButton import com.tangem.feature.swap.models.SwapPermissionState +import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.ImmutableList @Composable -fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, onCancel: () -> Unit) { +fun SwapPermissionBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet(config) { content: GivePermissionBottomSheetConfig -> + SwapPermissionBottomSheetContent(content = content) + } +} + +@Composable +private fun SwapPermissionBottomSheetContent(content: GivePermissionBottomSheetConfig) { var isPermissionAlertShow by remember { mutableStateOf(false) } + val data = content.data Column( modifier = Modifier .background(color = TangemTheme.colors.background.primary) @@ -33,10 +43,6 @@ fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, .padding(horizontal = TangemTheme.dimens.spacing16), horizontalAlignment = Alignment.CenterHorizontally, ) { - Hand() - - SpacerH10() - Box(modifier = Modifier.fillMaxWidth()) { Text( modifier = Modifier.align(Alignment.Center), @@ -87,7 +93,7 @@ fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, text = stringResource(id = R.string.common_cancel), modifier = Modifier.fillMaxWidth(), onClick = { - onCancel() + content.onCancel() }, ) @@ -261,7 +267,7 @@ private fun DropdownSelector( @Composable private fun FeeItem(fee: String) { InformationItem( - subtitle = stringResource(id = R.string.send_fee_label), + subtitle = stringResource(id = R.string.common_fee_label), value = fee, ) } @@ -288,7 +294,7 @@ private fun getTitleForApproveType(approveType: ApproveType): String = when (app @Composable private fun Preview_AgreementBottomSheet_InLightTheme() { TangemTheme(isDark = false) { - SwapPermissionBottomSheetContent(data = previewData) {} + SwapPermissionBottomSheetContent(content = previewData) } } @@ -296,20 +302,21 @@ private fun Preview_AgreementBottomSheet_InLightTheme() { @Composable private fun Preview_AgreementBottomSheet_InDarkTheme() { TangemTheme(isDark = true) { - SwapPermissionBottomSheetContent(data = previewData) {} + SwapPermissionBottomSheetContent(content = previewData) } } -private val previewData = SwapPermissionState.ReadyForRequest( - currency = "DAI", - amount = "∞", - walletAddress = "", - spenderAddress = "", - fee = TextReference.Str("2,14$"), - approveType = ApproveType.UNLIMITED, - approveButton = ApprovePermissionButton(true) {}, - cancelButton = CancelPermissionButton(true), - onChangeApproveType = { ApproveType.UNLIMITED }, -) - -//endregion preview \ No newline at end of file +private val previewData = GivePermissionBottomSheetConfig( + data = SwapPermissionState.ReadyForRequest( + currency = "DAI", + amount = "∞", + walletAddress = "", + spenderAddress = "", + fee = TextReference.Str("2,14$"), + approveType = ApproveType.UNLIMITED, + approveButton = ApprovePermissionButton(true) {}, + cancelButton = CancelPermissionButton(true), + onChangeApproveType = { ApproveType.UNLIMITED }, + ), + onCancel = {}, +) \ 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 850d4434df..a70b6ce6f2 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,99 +1,42 @@ package com.tangem.feature.swap.ui import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.padding import androidx.compose.material.* +import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.swap.models.SwapPermissionState import com.tangem.feature.swap.models.SwapStateHolder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch +import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig +import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig +import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig -@OptIn(ExperimentalMaterialApi::class) @Composable internal fun SwapScreen(stateHolder: SwapStateHolder) { - val coroutineScope = rememberCoroutineScope() - val bottomSheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = true, - ) - BackHandler( - onBack = { - if (bottomSheetState.isVisible) { - hideBottomSheet(coroutineScope, stateHolder, bottomSheetState) - } else { - stateHolder.onBackClicked() - } - }, - ) + BackHandler(onBack = stateHolder.onBackClicked) - LaunchedEffect(bottomSheetState.targetValue) { - if (bottomSheetState.targetValue == ModalBottomSheetValue.Hidden) { - stateHolder.onCancelPermissionBottomSheet.invoke() + Scaffold( + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + + SwapScreenContent( + state = stateHolder, + modifier = Modifier.padding(scaffoldPaddings), + ) + + stateHolder.bottomSheetConfig?.let { config -> + when (config.content) { + is GivePermissionBottomSheetConfig -> { + SwapPermissionBottomSheet(config = config) + } + is ChooseProviderBottomSheetConfig -> { + ChooseProviderBottomSheet(config = config) + } + is ChooseFeeBottomSheetConfig -> { + ChooseFeeBottomSheet(config = config) + } + } } } - - ModalBottomSheetLayout( - modifier = Modifier.systemBarsPadding(), - sheetContent = { - if (stateHolder.permissionState is SwapPermissionState.ReadyForRequest) { - SwapPermissionBottomSheetContent( - data = stateHolder.permissionState, - onCancel = { - hideBottomSheet(coroutineScope, stateHolder, bottomSheetState) - }, - ) - } else { - // Required "else" block to prevent compose crash - // always close BS if its empty, cause user should not see this - LaunchedEffect(Unit) { - coroutineScope.launch { bottomSheetState.hide() } - } - Box(modifier = Modifier.fillMaxSize()) - } - }, - sheetState = bottomSheetState, - sheetShape = RoundedCornerShape( - topStart = TangemTheme.dimens.radius16, - topEnd = TangemTheme.dimens.radius16, - ), - sheetElevation = TangemTheme.dimens.elevation24, - scrimColor = TangemTheme.colors.background.secondary.copy(alpha = 0.4f), - content = { - SwapScreenContent( - state = stateHolder, - onPermissionWarningClick = { - val isBottomSheetReady = !bottomSheetState.isVisible && - stateHolder.permissionState is SwapPermissionState.ReadyForRequest - coroutineScope.launch { - if (isBottomSheetReady) { - bottomSheetState.show() - stateHolder.onShowPermissionBottomSheet.invoke() - } else { - bottomSheetState.hide() - } - } - }, - ) - }, - ) -} - -@OptIn(ExperimentalMaterialApi::class) -private fun hideBottomSheet( - coroutineScope: CoroutineScope, - stateHolder: SwapStateHolder, - bottomSheetState: ModalBottomSheetState, -) { - coroutineScope.launch { - stateHolder.onCancelPermissionBottomSheet.invoke() - bottomSheetState.hide() - } } \ No newline at end of file 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 703f7bb82c..949ead0f64 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 @@ -7,10 +7,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue @@ -20,45 +18,34 @@ 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.states.Item -import com.tangem.core.ui.components.states.SelectableItemsState -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.getActiveIconRes +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.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.TxFee import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.presentation.R -import kotlinx.collections.immutable.toImmutableList -import java.math.BigDecimal @Suppress("LongMethod") @Composable -internal fun SwapScreenContent(state: SwapStateHolder, onPermissionWarningClick: () -> Unit) { +internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) { val keyboard by keyboardAsState() Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(color = TangemTheme.colors.background.secondary), ) { - Image( - modifier = Modifier - .size(width = TangemTheme.dimens.size164, height = TangemTheme.dimens.size80) - .align(Alignment.BottomCenter) - .padding(bottom = TangemTheme.dimens.spacing28), - painter = painterResource(id = R.drawable.ill_one_inch_powered), - contentDescription = null, - contentScale = ContentScale.Fit, - ) - Column { AppBarWithBackButton( text = stringResource(R.string.common_swap), onBackClick = state.onBackClicked, iconRes = R.drawable.ic_close_24, ) + Column( modifier = Modifier .fillMaxWidth() @@ -74,11 +61,20 @@ internal fun SwapScreenContent(state: SwapStateHolder, onPermissionWarningClick: ) { MainInfo(state) - FeeItem(feeState = state.fee, currency = state.networkCurrency) + ProviderItemBlock( + state = state.providerState, + modifier = Modifier + .clickable( + enabled = state.providerState.onProviderClick != null, + onClick = { state.providerState.onProviderClick?.invoke(state.providerState.id) }, + ), + ) + + FeeItemBlock(state = state.fee) if (state.warnings.isNotEmpty()) SwapWarnings(warnings = state.warnings) - if (state.permissionState is SwapPermissionState.InProgress) { + 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), @@ -92,7 +88,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, onPermissionWarningClick: }, ) } - MainButton(state = state, onPermissionWarningClick = onPermissionWarningClick) + MainButton(state = state, onPermissionWarningClick = state.onShowPermissionBottomSheet) } } @@ -137,53 +133,24 @@ private fun MainInfo(state: SwapStateHolder) { ConstraintLayout( modifier = Modifier.fillMaxWidth(), ) { - val networkIconRes = remember { - getActiveIconRes(state.blockchainId) - } val (topCard, bottomCard, button) = createRefs() val priceImpactWarning = state.warnings.filterIsInstance().firstOrNull() - TransactionCard( - type = state.sendCardData.type, - balance = if (state.sendCardData.isBalanceHidden) { - STARS - } else { - state.sendCardData.balance - }, - textFieldValue = state.sendCardData.amountTextFieldValue, - amountEquivalent = state.sendCardData.amountEquivalent, - tokenIconUrl = state.sendCardData.tokenIconUrl, - tokenCurrency = state.sendCardData.tokenCurrency, - priceImpact = priceImpactWarning, - networkIconRes = if (state.sendCardData.isNotNativeToken) networkIconRes else null, - iconPlaceholder = state.sendCardData.coinId?.let { - getActiveIconResByCoinId(it) - }, - onChangeTokenClick = if (state.sendCardData.canSelectAnotherToken) state.onSelectTokenClick else null, + TransactionCardData( + priceImpactWarning = priceImpactWarning, + swapCardState = state.sendCardData, modifier = Modifier.constrainAs(topCard) { top.linkTo(parent.top) }, + onSelectTokenClick = state.onSelectTokenClick, ) val marginCard = TangemTheme.dimens.spacing16 - TransactionCard( - type = state.receiveCardData.type, - balance = if (state.receiveCardData.isBalanceHidden) STARS else state.receiveCardData.balance, - textFieldValue = state.receiveCardData.amountTextFieldValue, - amountEquivalent = state.receiveCardData.amountEquivalent, - tokenIconUrl = state.receiveCardData.tokenIconUrl, - tokenCurrency = state.receiveCardData.tokenCurrency, - priceImpact = priceImpactWarning, - networkIconRes = if (state.receiveCardData.isNotNativeToken) networkIconRes else null, - iconPlaceholder = state.receiveCardData.coinId?.let { - getActiveIconResByCoinId(it) - }, - onChangeTokenClick = if (state.receiveCardData.canSelectAnotherToken) { - state.onSelectTokenClick - } else { - null - }, + TransactionCardData( + priceImpactWarning = priceImpactWarning, + swapCardState = state.receiveCardData, modifier = Modifier.constrainAs(bottomCard) { top.linkTo(topCard.bottom, margin = marginCard) }, + onSelectTokenClick = state.onSelectTokenClick, ) val marginButton = TangemTheme.dimens.spacing32 SwapButton( @@ -197,6 +164,47 @@ private fun MainInfo(state: SwapStateHolder) { } } +@Composable +private fun TransactionCardData( + priceImpactWarning: SwapWarning.HighPriceImpact?, + swapCardState: SwapCardState, + onSelectTokenClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + when (swapCardState) { + is SwapCardState.Empty -> { + TransactionCardEmpty( + type = swapCardState.type, + amountEquivalent = swapCardState.amountEquivalent, + textFieldValue = swapCardState.amountTextFieldValue, + onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, + modifier = modifier, + ) + } + is SwapCardState.SwapCardData -> { + TransactionCard( + type = swapCardState.type, + balance = if (swapCardState.isBalanceHidden) { + STARS + } else { + swapCardState.balance + }, + textFieldValue = swapCardState.amountTextFieldValue, + amountEquivalent = swapCardState.amountEquivalent, + tokenIconUrl = swapCardState.tokenIconUrl ?: "", + tokenCurrency = swapCardState.tokenCurrency, + priceImpact = priceImpactWarning, + networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null, + iconPlaceholder = swapCardState.coinId?.let { + getActiveIconResByCoinId(it) + }, + onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, + modifier = modifier, + ) + } + } +} + @OptIn(ExperimentalMaterialApi::class) @Composable private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) { @@ -228,48 +236,7 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) { } } -@Composable -private fun FeeItem(feeState: FeeState, currency: String) { - val titleString = stringResource(id = R.string.send_network_fee_title) - val disclaimer = stringResource(id = R.string.swapping_tangem_fee_disclaimer, "${feeState.tangemFee}%") - when (feeState) { - is FeeState.Loaded -> { - if (feeState.state != null) { - SelectableInfoCard( - state = feeState.state, - disclaimer = disclaimer, - onSelect = feeState.onSelectItem, - ) - } - } - FeeState.Loading -> { - SmallInfoCardWithDisclaimer( - startText = titleString, - endText = "", - disclaimer = disclaimer, - isLoading = true, - ) - } - is FeeState.NotEnoughFundsWarning -> { - if (feeState.state != null) { - SelectableInfoCardWithWarning( - state = feeState.state, - warningText = stringResource( - id = R.string.swapping_not_enough_funds_for_fee, - currency, - currency, - ), - disclaimer = disclaimer, - onSelect = feeState.onSelectItem, - ) - } - } - is FeeState.Empty -> { - SmallInfoCard(startText = titleString, endText = "") - } - } -} - +@Suppress("LongMethod") @Composable private fun SwapWarnings(warnings: List) { Column( @@ -280,26 +247,14 @@ private fun SwapWarnings(warnings: List) { warnings.forEach { warning -> when (warning) { is SwapWarning.HighPriceImpact -> { - WarningCard( - title = stringResource(id = R.string.swapping_high_price_impact), - description = stringResource(id = R.string.swapping_high_price_impact_description), + Notification( + config = warning.notificationConfig, + iconTint = TangemTheme.colors.icon.warning, ) } is SwapWarning.PermissionNeeded -> { - WarningCard( - title = stringResource(id = R.string.swapping_permission_header), - description = stringResource( - id = R.string.swapping_permission_subheader, - warning.tokenCurrency, - ), - icon = { - Icon( - painter = painterResource(id = com.tangem.core.ui.R.drawable.ic_locked_24), - contentDescription = null, - modifier = Modifier.size(TangemTheme.dimens.size20), - tint = TangemTheme.colors.icon.primary1, - ) - }, + Notification( + config = warning.notificationConfig, ) } is SwapWarning.GenericWarning -> { @@ -316,14 +271,28 @@ private fun SwapWarnings(warnings: List) { onClick = warning.onClick, ) } + is SwapWarning.NoAvailableTokensToSwap -> { + Notification( + config = warning.notificationConfig, + ) + } + is SwapWarning.TooSmallAmountWarning -> { + Notification( + config = warning.notificationConfig, + iconTint = TangemTheme.colors.icon.warning, + ) + } + is SwapWarning.UnableToCoverFeeWarning -> { + Notification( + config = warning.notificationConfig, + ) + } + is SwapWarning.GeneralWarning -> { + Notification( + config = warning.notificationConfig, + ) + } else -> {} - // is SwapWarning.RateExpired -> { - // RefreshableWaringCard( - // title = stringResource(id = R.string.), - // description = stringResource(id = R.string.), - // onClick = warning.onClick, - // ) - // } } SpacerH8() } @@ -367,7 +336,7 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U // region preview -private val sendCard = SwapCardData( +private val sendCard = SwapCardState.SwapCardData( type = TransactionCardType.SendCard({}) {}, amountTextFieldValue = TextFieldValue(), amountEquivalent = "1 000 000", @@ -377,10 +346,12 @@ private val sendCard = SwapCardData( canSelectAnotherToken = false, balance = "123", coinId = "", + token = null, + networkIconRes = R.drawable.img_polygon_22, isBalanceHidden = false, ) -private val receiveCard = SwapCardData( +private val receiveCard = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountTextFieldValue = TextFieldValue(), amountEquivalent = "1 000 000", @@ -390,63 +361,39 @@ private val receiveCard = SwapCardData( canSelectAnotherToken = true, balance = "33333", coinId = "", + token = null, + networkIconRes = R.drawable.img_polygon_22, isBalanceHidden = false, ) -val stateSelectable = SelectableItemsState( - selectedItem = Item( - 0, - TextReference.Str("Balance"), - TextReference.Str("0.4405434 BTC"), - true, - TxFee( - feeValue = BigDecimal.ZERO, - gasLimit = 0, - feeFiatFormatted = "", - feeCryptoFormatted = "", - feeType = FeeType.NORMAL, - ), - ), - items = listOf( - Item( - 0, - TextReference.Str("Normal"), - TextReference.Str("0.4405434 BTC"), - true, - TxFee( - feeValue = BigDecimal.ZERO, - gasLimit = 0, - feeFiatFormatted = "", - feeCryptoFormatted = "", - feeType = FeeType.NORMAL, - ), - ), - Item( - 1, - TextReference.Str("Priority"), - TextReference.Str("0.46 BTC"), - false, - TxFee( - feeValue = BigDecimal.ZERO, - gasLimit = 0, - feeFiatFormatted = "", - feeCryptoFormatted = "", - feeType = FeeType.NORMAL, - ), - ), - ).toImmutableList(), -) - private val state = SwapStateHolder( - networkId = "ethereum", sendCardData = sendCard, receiveCardData = receiveCard, - fee = FeeState.Loaded( - tangemFee = 0.0, - state = stateSelectable, - onSelectItem = {}, + fee = FeeItemState.Content( + feeType = FeeType.NORMAL, + title = stringReference("Fee"), + amountCrypto = "100", + symbolCrypto = "1000", + amountFiatFormatted = "(100)", + isClickable = true, + onClick = {}, + ), + warnings = listOf( + SwapWarning.PermissionNeeded( + notificationConfig = NotificationConfig( + title = stringReference("Give Premission"), + subtitle = stringReference("To continue swapping you need to give permission to Tangem"), + iconResId = R.drawable.ic_locked_24, + ), + ), + SwapWarning.NoAvailableTokensToSwap( + notificationConfig = NotificationConfig( + title = stringReference("No tokens"), + subtitle = stringReference("Swap tokens not available"), + iconResId = R.drawable.img_attention_20, + ), + ), ), - warnings = listOf(SwapWarning.PermissionNeeded("DAI")), networkCurrency = "MATIC", swapButton = SwapButton(enabled = true, loading = false, onClick = {}), onRefresh = {}, @@ -454,13 +401,14 @@ private val state = SwapStateHolder( onChangeCardsClicked = {}, permissionState = SwapPermissionState.InProgress, blockchainId = "POLYGON", + providerState = ProviderState.Loading(), ) @Preview @Composable private fun SwapScreenContentPreview() { TangemTheme(isDark = false) { - SwapScreenContent(state = state) {} + SwapScreenContent(state = state, modifier = Modifier) } } 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 80d9af4c8a..82b05a7c86 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 @@ -1,55 +1,52 @@ package com.tangem.feature.swap.ui -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Divider import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity 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 -import coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest import com.tangem.common.Strings -import com.tangem.core.ui.components.CircleShimmer -import com.tangem.core.ui.components.CurrencyPlaceholderIcon -import com.tangem.core.ui.components.SpacerW2 +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.ExpandableSearchView -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.components.currency.tokenicon.TokenIcon +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.utils.ImageBackgroundContrastChecker -import com.tangem.feature.swap.models.Network -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenBalanceData -import com.tangem.feature.swap.models.TokenToSelect +import com.tangem.feature.swap.models.* import com.tangem.feature.swap.presentation.R -import kotlinx.coroutines.launch +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList @Composable fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { + BackHandler(onBack = onBack) + Scaffold( modifier = Modifier .systemBarsPadding() .background(color = TangemTheme.colors.background.secondary), content = { padding -> - ListOfTokens(state = state, Modifier.padding(padding)) + val modifier = Modifier.padding(padding) + if (state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty()) { + EmptyTokensList(modifier) + } else { + ListOfTokens(state = state, modifier = modifier) + } }, topBar = { ExpandableSearchView( @@ -58,61 +55,90 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) placeholderSearchText = stringResource(id = R.string.common_search_tokens), onSearchChange = state.onSearchEntered, onSearchDisplayClose = { state.onSearchEntered("") }, - subtitle = state.network.name, - icon = painterResource(id = getActiveIconRes(state.network.blockchainId)), + subtitle = stringResource(id = R.string.express_exchange_token_list_subtitle), ) }, ) } -@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun EmptyTokensList(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillMaxSize(), + ) { + Column(modifier = Modifier.align(Alignment.Center)) { + Image( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.ic_no_token_44), + colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), + contentDescription = null, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.exchange_tokens_empty_tokens), + style = TangemTheme.typography.caption2, + 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) - .fillMaxWidth(), + .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (state.addedTokens.isNotEmpty()) { - stickyHeader { Header(title = R.string.swapping_token_list_your_tokens) } - } + item { SpacerH8() } - itemsIndexed(items = state.addedTokens) { index, item -> - TokenItem( - token = item, - network = state.network, - screenBackgroundColor = screenBackgroundColor, - onTokenClick = { - state - .onTokenSelected(item.id) - }, - ) + tokensToSelectItems(state.availableTokens, state.onTokenSelected) - if (index != state.addedTokens.lastIndex) { - Divider( - color = TangemTheme.colors.stroke.primary, - startIndent = TangemTheme.dimens.spacing54, + item { SpacerH12() } + + tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + + item { SpacerH12() } + } +} + +private fun LazyListScope.tokensToSelectItems( + items: ImmutableList, + onTokenClick: (String) -> Unit, +) { + itemsIndexed(items = items) { index, item -> + when (item) { + is TokenToSelectState.Title -> { + TitleHeader( + item = item, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = items.lastIndex, + ), ) } - } - - if (state.otherTokens.isNotEmpty()) { - stickyHeader { Header(title = R.string.swapping_token_list_other_tokens) } - } - - itemsIndexed(items = state.otherTokens) { index, item -> - TokenItem( - token = item, - network = state.network, - screenBackgroundColor = screenBackgroundColor, - onTokenClick = { state.onTokenSelected(item.id) }, - ) - if (index != state.otherTokens.lastIndex) { - Divider( - color = TangemTheme.colors.stroke.primary, - startIndent = TangemTheme.dimens.spacing54, + is TokenToSelectState.TokenToSelect -> { + TokenItem( + token = item, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = items.lastIndex, + ) + .background(TangemTheme.colors.background.action), + onTokenClick = { + onTokenClick(item.id) + }, ) } } @@ -120,28 +146,39 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = } @Composable -private fun Header(@StringRes title: Int) { - Text( - text = stringResource(id = title).uppercase(), - style = TangemTheme.typography.overline, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier +private fun TitleHeader(item: TokenToSelectState.Title, modifier: Modifier = Modifier) { + Box( + modifier = modifier .fillMaxWidth() - .padding( - vertical = TangemTheme.dimens.spacing6, - horizontal = TangemTheme.dimens.spacing16, - ), - textAlign = TextAlign.Start, - ) + .background(TangemTheme.colors.background.action), + ) { + Text( + text = item.title.resolveReference().uppercase(), + style = TangemTheme.typography.overline, + modifier = Modifier + .padding( + top = TangemTheme.dimens.spacing16, + start = TangemTheme.dimens.spacing16, + ), + ) + } } @Suppress("LongMethod") @Composable -private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundColor: Color, onTokenClick: () -> Unit) { +private fun TokenItem( + token: TokenToSelectState.TokenToSelect, + onTokenClick: () -> Unit, + modifier: Modifier = Modifier, +) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .clickable(onClick = onTokenClick) + .height(TangemTheme.dimens.size72) + .clickable( + enabled = token.available, + onClick = onTokenClick, + ) .padding( vertical = TangemTheme.dimens.spacing14, horizontal = TangemTheme.dimens.spacing16, @@ -149,12 +186,15 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo verticalAlignment = Alignment.CenterVertically, ) { TokenIcon( - token = token, - screenBackgroundColor = screenBackgroundColor, - iconPlaceholder = if (token.isNative) getActiveIconRes(network.blockchainId) else null, + state = token.tokenIcon, + shouldDisplayNetwork = true, ) - Column(modifier = Modifier.align(Alignment.CenterVertically)) { + Column( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = TangemTheme.dimens.spacing12), + ) { Text( text = token.name, style = TangemTheme.typography.subtitle1, @@ -174,13 +214,7 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo Spacer(modifier = Modifier.weight(1f)) - if (!token.available) { - Text( - text = stringResource(id = R.string.swapping_token_not_available), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - } else if (token.addedTokenBalanceData != null) { + if (token.addedTokenBalanceData != null) { Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Center, @@ -195,7 +229,11 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo token.addedTokenBalanceData.amountEquivalent.orEmpty() }, style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, + color = if (token.available) { + TangemTheme.colors.text.primary1 + } else { + TangemTheme.colors.text.tertiary + }, ) SpacerW2() Text( @@ -214,73 +252,16 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo } } -@Suppress("MagicNumber") -@Composable -private fun TokenIcon(token: TokenToSelect, screenBackgroundColor: Color, @DrawableRes iconPlaceholder: Int?) { - var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) } - var isBackgroundColorDefined by remember { mutableStateOf(false) } - val isDarkTheme = isSystemInDarkTheme() - val coroutineScope = rememberCoroutineScope() - - val data = token.iconUrl.ifEmpty { - iconPlaceholder - } - Box( - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing12) - .background( - color = iconBackgroundColor, - shape = TangemTheme.shapes.roundedCorners8, - ), - ) { - val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size40.roundToPx() } - val iconModifier = Modifier.size(TangemTheme.dimens.size40) - - val colorFilter = if (!token.available) { - val matrix = ColorMatrix().apply { setToSaturation(0f) } - ColorFilter.colorMatrix(matrix) - } else { - null - } - - SubcomposeAsyncImage( - modifier = iconModifier, - model = ImageRequest.Builder(LocalContext.current) - .data(data) - .size(size = pixelsSize) - .memoryCacheKey(key = data.toString() + pixelsSize) - .crossfade(true) - .allowHardware(false) - .listener( - onSuccess = { _, result -> - if (!isBackgroundColorDefined && isDarkTheme) { - coroutineScope.launch { - val color = ImageBackgroundContrastChecker( - drawable = result.drawable, - backgroundColor = screenBackgroundColor.toArgb(), - size = pixelsSize, - ).getContrastColor(isDarkTheme = true) - iconBackgroundColor = color - isBackgroundColorDefined = true - } - } - }, - ).build(), - contentDescription = token.id, - loading = { CircleShimmer(modifier = iconModifier) }, - error = { CurrencyPlaceholderIcon(modifier = iconModifier, id = token.id) }, - alpha = if (!token.available) 0.7f else 1f, - colorFilter = colorFilter, - ) - } -} - -private val token = TokenToSelect( +private val token = TokenToSelectState.TokenToSelect( + tokenIcon = TokenIconState.CoinIcon( + url = "", + fallbackResId = 0, + isGrayscale = false, + showCustomBadge = false, + ), id = "", name = "USDC", symbol = "USDC", - iconUrl = "", - isNative = false, addedTokenBalanceData = TokenBalanceData( amount = "15 000 $", amountEquivalent = "15 000 " + @@ -289,17 +270,30 @@ private val token = TokenToSelect( ), ) +private val title = TokenToSelectState.Title( + title = stringReference("MY TOKENS"), +) + @Preview @Composable private fun TokenScreenPreview() { - SwapSelectTokenScreen( - state = SwapSelectTokenStateHolder( - addedTokens = listOf(token, token, token), - otherTokens = listOf(token, token, token), - onSearchEntered = {}, - onTokenSelected = {}, - network = Network("Ethereum", "ETH"), - ), - onBack = {}, - ) + TangemTheme(isDark = false) { + SwapSelectTokenScreen( + state = SwapSelectTokenStateHolder( + availableTokens = listOf(title, token, token, token).toImmutableList(), + unavailableTokens = listOf(title, token, token, token).toImmutableList(), + onSearchEntered = {}, + onTokenSelected = {}, + ), + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun EmptyTokensListPreview() { + TangemTheme(isDark = false) { + EmptyTokensList() + } } \ No newline at end of file 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 a05fbdb29b..d540514a81 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 @@ -1,18 +1,24 @@ package com.tangem.feature.swap.ui -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip 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.tooling.preview.Preview -import com.tangem.core.ui.components.ResultScreenContent +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.components.inputrow.InputRowBestRate +import com.tangem.core.ui.components.inputrow.InputRowDefault +import com.tangem.core.ui.components.inputrow.InputRowImage +import com.tangem.core.ui.components.transactions.TransactionDoneTitle +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.models.SwapSuccessStateHolder import com.tangem.feature.swap.presentation.R @@ -21,41 +27,118 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) { Scaffold( modifier = Modifier.systemBarsPadding(), content = { padding -> - ResultScreenContent( - resultMessage = makeSuccessMessage( - fromTokenAmount = state.fromTokenAmount, - toTokenAmount = state.toTokenAmount, - ), - resultColor = TangemTheme.colors.icon.attention, - onButtonClick = onBack, - icon = R.drawable.ic_clock_24, - secondaryButtonIcon = R.drawable.ic_arrow_top_right_24, - onSecondaryButtonClick = state.onSecondaryButtonClick, - secondaryButtonText = R.string.swapping_success_view_explorer_button_title, - title = R.string.swapping_success_view_title, - modifier = Modifier.padding(padding), - ) + SwapSuccessScreenContent(padding = padding, state = state) }, topBar = { AppBarWithBackButton( - text = stringResource(R.string.common_swap), onBackClick = onBack, iconRes = R.drawable.ic_close_24, ) }, + bottomBar = { + SwapSuccessScreenButtons( + textRes = R.string.common_close, + txUrl = state.txUrl, + showStatusButton = state.showStatusButton, + onExploreClick = state.onExploreButtonClick, + onStatusClick = state.onStatusButtonClick, + onDoneClick = onBack, + ) + }, ) } @Composable -private fun makeSuccessMessage(fromTokenAmount: String, toTokenAmount: String): AnnotatedString { - val swapToString = stringResource(id = R.string.swapping_swap_of_to, fromTokenAmount) - val message = "$swapToString\n$toTokenAmount" - return buildAnnotatedString { - append(message) - addStyle( - style = SpanStyle(color = TangemTheme.colors.text.accent), - start = message.indexOf(toTokenAmount), - end = message.length, +private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + TransactionDoneTitle(titleRes = R.string.swapping_success_view_title, date = state.timestamp) + SpacerH16() + InputRowImage( + title = TextReference.Res(R.string.swapping_success_from_title), + subtitle = state.fromTokenAmount, + caption = state.fromTokenFiatAmount, + tokenIconState = state.fromTokenIconState ?: TokenIconState.Loading, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + SpacerH16() + InputRowImage( + title = TextReference.Res(R.string.swapping_success_to_title), + subtitle = state.toTokenAmount, + caption = state.toTokenFiatAmount, + tokenIconState = state.toTokenIconState ?: TokenIconState.Loading, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + SpacerH16() + InputRowBestRate( + imageUrl = state.providerIcon, + title = state.providerName, + titleExtra = state.providerType, + subtitle = state.rate, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + SpacerH16() + InputRowDefault( + title = TextReference.Res(R.string.common_fee_label), + text = state.fee, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun SwapSuccessScreenButtons( + @StringRes textRes: Int, + txUrl: String, + showStatusButton: Boolean, + onExploreClick: () -> Unit, + onStatusClick: () -> Unit, + onDoneClick: () -> Unit, +) { + Column( + modifier = Modifier + .background(TangemTheme.colors.background.secondary) + .padding(TangemTheme.dimens.spacing16), + ) { + if (txUrl.isNotBlank()) { + Row { + SecondaryButtonIconStart( + text = stringResource(id = R.string.common_explore), + iconResId = R.drawable.ic_web_24, + onClick = onExploreClick, + modifier = Modifier.weight(1f), + ) + if (showStatusButton) { + SpacerW12() + SecondaryButtonIconStart( + text = stringResource(id = R.string.express_cex_status_button_title), + iconResId = R.drawable.ic_arrow_top_right_24, + onClick = onStatusClick, + modifier = Modifier.weight(1f), + ) + } + } + SpacerH12() + } + PrimaryButton( + text = stringResource(id = textRes), + enabled = true, + onClick = onDoneClick, + modifier = Modifier.fillMaxWidth(), ) } } @@ -63,9 +146,23 @@ private fun makeSuccessMessage(fromTokenAmount: String, toTokenAmount: String): // region preview private val state = SwapSuccessStateHolder( - fromTokenAmount = "1 000 DAI", - toTokenAmount = "1 131,46 MATIC", -) {} + timestamp = 0L, + txUrl = "https://www.google.com/#q=nam", + fee = TextReference.Str("1 000 DAI ~ 1 000 MATIC"), + providerName = TextReference.Str("1inch"), + providerType = TextReference.Str(ExchangeProviderType.DEX.name), + showStatusButton = false, + providerIcon = "", + fromTokenAmount = TextReference.Str("1 000 DAI"), + toTokenAmount = TextReference.Str("1 000 MATIC"), + fromTokenFiatAmount = TextReference.Str("1 000 $"), + toTokenFiatAmount = TextReference.Str("1 000 $"), + fromTokenIconState = TokenIconState.Loading, + toTokenIconState = TokenIconState.Loading, + rate = TextReference.Str("1 000 DAI ~ 1 000 MATIC"), + onExploreButtonClick = {}, + onStatusButtonClick = {}, +) @Preview(showBackground = true) @Composable 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 3cbd35a23f..b16a5e4c21 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 @@ -1,6 +1,7 @@ package com.tangem.feature.swap.ui import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -9,7 +10,6 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -17,6 +17,7 @@ import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext @@ -55,53 +56,114 @@ fun TransactionCard( @DrawableRes networkIconRes: Int? = null, onChangeTokenClick: (() -> Unit)? = null, ) { - Card( - shape = RoundedCornerShape(TangemTheme.dimens.radius12), - backgroundColor = TangemTheme.colors.background.primary, - elevation = TangemTheme.dimens.elevation2, - modifier = modifier, + Box( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius12), + color = TangemTheme.colors.background.primary, + ) + .fillMaxSize(), ) { - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start, - ) { - Header(balance = balance, type = type) + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + ) { + Header(balance = stringResource(R.string.common_balance, balance), type = type) - Content( - type = type, - amountEquivalent = amountEquivalent, - textFieldValue = textFieldValue, - priceImpact = priceImpact, - ) - } + Content( + type = type, + amountEquivalent = amountEquivalent, + textFieldValue = textFieldValue, + priceImpact = priceImpact, + ) + } - Box(modifier = Modifier.align(Alignment.BottomEnd)) { - Token( - tokenIconUrl = tokenIconUrl, - tokenCurrency = tokenCurrency, - networkIconRes = networkIconRes, - iconPlaceholder = iconPlaceholder, - ) - } + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + Token( + tokenIconUrl = tokenIconUrl, + tokenCurrency = tokenCurrency, + networkIconRes = networkIconRes, + iconPlaceholder = iconPlaceholder, + ) + } - if (onChangeTokenClick != null) { - Box(modifier = Modifier.align(Alignment.CenterEnd)) { - ChangeTokenSelector() - } - Box( - Modifier - .align(Alignment.CenterEnd) - .height(TangemTheme.dimens.size116) - .width(TangemTheme.dimens.size102) - .clickable( - indication = rememberRipple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - ) { onChangeTokenClick() }, - ) + if (onChangeTokenClick != null) { + Box(modifier = Modifier.align(Alignment.CenterEnd)) { + ChangeTokenSelector() } + Box( + Modifier + .align(Alignment.CenterEnd) + .height(TangemTheme.dimens.size116) + .width(TangemTheme.dimens.size102) + .clickable( + indication = rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + ) { onChangeTokenClick() }, + ) + } + } +} + +@Composable +fun TransactionCardEmpty( + type: TransactionCardType, + amountEquivalent: String?, + textFieldValue: TextFieldValue?, + modifier: Modifier = Modifier, + onChangeTokenClick: (() -> Unit)? = null, +) { + Box( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius12), + color = TangemTheme.colors.background.primary, + ) + .fillMaxSize(), + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + ) { + Header( + balance = stringResource(id = R.string.swapping_token_not_available), + type = type, + ) + + Content( + type = type, + amountEquivalent = amountEquivalent, + textFieldValue = textFieldValue, + priceImpact = null, + ) + } + + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + Token( + tokenIconUrl = "", + tokenCurrency = "", + iconPlaceholder = R.drawable.ic_no_token_44, + ) + } + + if (onChangeTokenClick != null) { + Box(modifier = Modifier.align(Alignment.CenterEnd)) { + ChangeTokenSelector() + } + Box( + Modifier + .align(Alignment.CenterEnd) + .height(TangemTheme.dimens.size116) + .width(TangemTheme.dimens.size102) + .clickable( + indication = rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + ) { onChangeTokenClick() }, + ) } } } @@ -133,14 +195,16 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie ) SpacerW16() if (balance.isNotBlank()) { - Text( - text = stringResource(R.string.common_balance, balance), - color = TangemTheme.colors.text.tertiary, - style = MaterialTheme.typography.body2, - modifier = Modifier - .defaultMinSize(minHeight = TangemTheme.dimens.size20) - .padding(top = TangemTheme.dimens.spacing2), - ) + AnimatedContent(targetState = balance, label = "") { + Text( + text = it, + color = TangemTheme.colors.text.tertiary, + style = MaterialTheme.typography.body2, + modifier = Modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size20) + .padding(top = TangemTheme.dimens.spacing2), + ) + } } else { RectangleShimmer( modifier = Modifier @@ -231,12 +295,14 @@ private fun Content( ) } } else { - Text( - text = amountEquivalent, - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.body2, - modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20), - ) + AnimatedContent(targetState = amountEquivalent, label = "") { + Text( + text = it, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20), + ) + } } } else { RectangleShimmer( @@ -311,6 +377,7 @@ private fun TokenIcon( color = iconBackgroundColor, shape = TangemTheme.shapes.roundedCorners8, ) + .clip(TangemTheme.shapes.roundedCorners8) val data = tokenIconUrl.ifEmpty { iconPlaceholder } @@ -376,6 +443,7 @@ fun ChangeTokenSelector() { Icon( modifier = Modifier.size(TangemTheme.dimens.size20), painter = painterResource(id = R.drawable.ic_chevron_24), + tint = TangemTheme.colors.icon.secondary, contentDescription = null, ) } 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 5b4b806568..7b5bd446fa 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 @@ -1,16 +1,31 @@ package com.tangem.feature.swap.viewmodels -import com.tangem.feature.swap.domain.models.domain.Currency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +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.RequestApproveStateData -import com.tangem.feature.swap.domain.models.ui.SwapStateData +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress import com.tangem.feature.swap.domain.models.ui.TxFee +import com.tangem.feature.swap.models.ApproveType data class SwapProcessDataState( + // Initial network id val networkId: String, - val fromCurrency: Currency? = null, - val toCurrency: Currency? = null, + val fromCryptoCurrency: CryptoCurrencyStatus? = null, + val toCryptoCurrency: CryptoCurrencyStatus? = null, + // Amount from input val amount: String? = null, val approveDataModel: RequestApproveStateData? = null, - val swapDataModel: SwapStateData? = null, - val selectedFee: TxFee? = null, -) \ No newline at end of file + val approveType: ApproveType? = null, + val swapDataModel: SwapDataModel? = null, + val selectedFee: TxFee? = null, // todo + val tokensDataState: TokensDataStateExpress? = null, + val selectedProvider: SwapProvider? = null, + val lastLoadedSwapStates: Map = emptyMap(), +) { + + fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? { + return lastLoadedSwapStates[selectedProvider] as? SwapState.QuotesLoadedState + } +} \ No newline at end of file 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 4740549f50..51bc827a3b 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 @@ -4,42 +4,49 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.* +import arrow.core.getOrElse +import arrow.core.mapNotNull import com.tangem.common.Provider 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.model.Network +import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus 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.domain.Currency +import com.tangem.feature.swap.domain.models.DataError 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.formatToUIRepresentation import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.feature.swap.models.SwapPermissionState -import com.tangem.feature.swap.models.SwapStateHolder -import com.tangem.feature.swap.models.UiActions -import com.tangem.feature.swap.models.toDomainApproveType +import com.tangem.feature.swap.models.* import com.tangem.feature.swap.presentation.SwapFragment import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.Debouncer -import com.tangem.utils.coroutines.runCatching +import com.tangem.utils.coroutines.* +import com.tangem.utils.isNullOrZero import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import timber.log.Timber +import java.math.BigDecimal +import java.math.RoundingMode import java.text.DecimalFormat import java.text.NumberFormat import java.util.Locale import javax.inject.Inject +import kotlin.math.absoluteValue import kotlin.properties.Delegates +typealias SuccessLoadedSwapData = Map + @Suppress("LargeClass", "LongParameterList") @HiltViewModel internal class SwapViewModel @Inject constructor( @@ -48,34 +55,36 @@ internal class SwapViewModel @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver { - private val currency = Json.decodeFromString( - savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY] - ?: error("no expected parameter Currency found"), - ) - private val derivationPath = savedStateHandle.get(SwapFragment.DERIVATION_PATH) - private val network = savedStateHandle.get(SwapFragment.NETWORK) + private val initialCryptoCurrency: CryptoCurrency = savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY] + ?: error("no expected parameter CryptoCurrency found`") + private lateinit var initialCryptoCurrencyStatus: CryptoCurrencyStatus private var isBalanceHidden = true + private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + private val stateBuilder = StateBuilder( actions = createUiActions(), isBalanceHiddenProvider = Provider { isBalanceHidden }, + appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), ) private val inputNumberFormatter = InputNumberFormatter(NumberFormat.getInstance(Locale.getDefault()) as DecimalFormat) private val amountDebouncer = Debouncer() - private val singleTaskScheduler = SingleTaskScheduler() + private val singleTaskScheduler = SingleTaskScheduler>() - private var dataState by mutableStateOf(SwapProcessDataState(networkId = currency.networkId)) + private var dataState by mutableStateOf(SwapProcessDataState(networkId = initialCryptoCurrency.network.backendId)) var uiState: SwapStateHolder by mutableStateOf( stateBuilder.createInitialLoadingState( - initialCurrency = currency, - networkInfo = blockchainInteractor.getBlockchainInfo(currency.networkId), + initialCurrency = initialCryptoCurrency, + networkInfo = blockchainInteractor.getBlockchainInfo(initialCryptoCurrency.network.backendId), ), ) private set @@ -88,8 +97,20 @@ internal class SwapViewModel @Inject constructor( get() = swapRouter.currentScreen init { - swapInteractor.initDerivationPathAndNetwork(derivationPath, network) - initTokens(currency) + viewModelScope.launch(dispatchers.io) { + swapInteractor.getSelectedWallet()?.let { + initialCryptoCurrencyStatus = + 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) { @@ -110,16 +131,15 @@ internal class SwapViewModel @Inject constructor( } fun onScreenOpened() { - analyticsEventHandler.send(SwapEvents.SwapScreenOpened(currency.symbol)) + analyticsEventHandler.send(SwapEvents.SwapScreenOpened(initialCryptoCurrency.symbol)) } fun setRouter(router: SwapRouter) { swapRouter = router uiState = uiState.copy( - onBackClicked = router::back, onSelectTokenClick = { router.openScreen(SwapNavScreen.SelectToken) - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened) + sendSelectTokenScreenOpenedEvent() }, onSuccess = { router.openScreen(SwapNavScreen.Success) @@ -127,60 +147,124 @@ internal class SwapViewModel @Inject constructor( ) } - private fun initTokens(currency: Currency) { + private fun sendSelectTokenScreenOpenedEvent() { + val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() ?: false + val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() ?: false + val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(availableTokens = isAnyAvailableTokens)) + } + + private fun initTokens() { viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { - swapInteractor.initTokensToSwap(currency) + swapInteractor.getTokensDataState(initialCryptoCurrency) + }.onSuccess { state -> + updateTokensState(state) + applyInitialTokenChoice( + state, + swapInteractor.selectInitialCurrencyToSwap( + initialCryptoCurrency, + state, + ), + ) + }.onFailure { + Timber.tag(loggingTag).e(it) + + applyInitialTokenChoice( + state = TokensDataStateExpress.EMPTY, + selectedCurrency = null, + ) + + uiState = stateBuilder.createInitialErrorState(uiState) { + uiState = stateBuilder.createInitialLoadingState( + initialCurrency = initialCryptoCurrency, + networkInfo = blockchainInteractor.getBlockchainInfo(initialCryptoCurrency.network.backendId), + ) + initTokens() + } } - .onSuccess { state -> - dataState = dataState.copy( - fromCurrency = state.preselectTokens.fromToken, - toCurrency = state.preselectTokens.toToken, - ) - updateTokensState(dataState = state.foundTokensState) - startLoadingQuotes( - fromToken = state.preselectTokens.fromToken, - toToken = state.preselectTokens.toToken, - amount = lastAmount.value, - ) - } - .onFailure { - Timber.e(it) - } } } - private fun updateTokensState(dataState: FoundTokensState) { + private fun applyInitialTokenChoice(state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?) { + val fromCurrencyStatus = initialCryptoCurrencyStatus + dataState = dataState.copy( + fromCryptoCurrency = fromCurrencyStatus, + toCryptoCurrency = selectedCurrency, + tokensDataState = state, + ) + if (selectedCurrency == null) { + analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap) + uiState = stateBuilder.createNoAvailableTokensToSwapState( + uiStateHolder = uiState, + fromToken = fromCurrencyStatus, + ) + } else { + startLoadingQuotes( + fromToken = fromCurrencyStatus, + toToken = selectedCurrency, + amount = lastAmount.value, + toProvidersList = findSwapProviders(fromCurrencyStatus, selectedCurrency), + ) + } + } + + private fun updateTokensState(dataState: TokensDataStateExpress) { + val tokensDataState = if (!isOrderReversed) dataState.toGroup else dataState.fromGroup uiState = stateBuilder.addTokensToState( uiState = uiState, - dataState = dataState, - networkInfo = blockchainInteractor.getBlockchainInfo(currency.networkId), + tokensDataState = tokensDataState, ) } - private fun startLoadingQuotes(fromToken: Currency, toToken: Currency, amount: String) { + private fun startLoadingQuotes( + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + amount: String, + toProvidersList: List, + isSilent: Boolean = false, + ) { singleTaskScheduler.cancelTask() - uiState = stateBuilder.createQuotesLoadingState(uiState, fromToken, toToken, currency.id) + if (!isSilent) { + uiState = stateBuilder.createQuotesLoadingState( + uiState, + fromToken.currency, + toToken.currency, + initialCryptoCurrency.id.value, + ) + } singleTaskScheduler.scheduleTask( viewModelScope, loadQuotesTask( fromToken = fromToken, toToken = toToken, amount = amount, + toProvidersList = toProvidersList, ), ) } - private fun startLoadingQuotesFromLastState() { - val fromCurrency = dataState.fromCurrency - val toCurrency = dataState.toCurrency + private fun startLoadingQuotesFromLastState(isSilent: Boolean = false) { + val fromCurrency = dataState.fromCryptoCurrency + val toCurrency = dataState.toCryptoCurrency val amount = dataState.amount if (fromCurrency != null && toCurrency != null && amount != null) { - startLoadingQuotes(fromCurrency, toCurrency, amount) + startLoadingQuotes( + fromToken = fromCurrency, + toToken = toCurrency, + amount = amount, + isSilent = isSilent, + toProvidersList = findSwapProviders(fromCurrency, toCurrency), + ) } } - private fun loadQuotesTask(fromToken: Currency, toToken: Currency, amount: String): PeriodicTask { + private fun loadQuotesTask( + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + amount: String, + toProvidersList: List, + ): PeriodicTask> { return PeriodicTask( UPDATE_DELAY, task = { @@ -195,35 +279,25 @@ internal class SwapViewModel @Inject constructor( networkId = dataState.networkId, fromToken = fromToken, toToken = toToken, + providers = toProvidersList, amountToSwap = amount, selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, ) } }, - onSuccess = { swapState -> - when (swapState) { - is SwapState.QuotesLoadedState -> { - fillDataState(swapState.permissionState, swapState.swapDataModel) - uiState = stateBuilder.createQuotesLoadedState( - uiStateHolder = uiState, - quoteModel = swapState, - fromToken = fromToken, - ) { updatedFee -> - dataState = dataState.copy( - selectedFee = updatedFee, - ) - } - } - is SwapState.EmptyAmountState -> { - uiState = stateBuilder.createQuotesEmptyAmountState( - uiStateHolder = uiState, - emptyAmountState = swapState, - ) - } - is SwapState.SwapError -> { - Timber.e("SwapError when loading quotes ${swapState.error}") - uiState = stateBuilder.mapError(uiState, swapState.error) { startLoadingQuotesFromLastState() } - } + onSuccess = { providersState -> + if (providersState.isNotEmpty()) { + val (provider, state) = updateLoadedQuotes(providersState) + setupLoadedState(provider, state, fromToken) + uiState = stateBuilder.updateProvidersBottomSheetContent( + uiState = uiState, + tokenSwapInfoForProviders = providersState + .getLastLoadedSuccessStates() + .entries + .associate { it.key.providerId to it.value.toTokenInfo }, + ) + } else { + Timber.e("Accidentally empty quotes list") } }, onError = { @@ -233,48 +307,181 @@ internal class SwapViewModel @Inject constructor( ) } - private fun fillDataState(permissionState: PermissionDataState, swapDataModel: SwapStateData?) { + private fun setupLoadedState(provider: SwapProvider, state: SwapState, fromToken: CryptoCurrencyStatus) { + when (state) { + is SwapState.QuotesLoadedState -> { + fillLoadedDataState(state, state.permissionState, state.swapDataModel) + val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() + val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId + uiState = stateBuilder.createQuotesLoadedState( + uiStateHolder = uiState, + quoteModel = state, + fromToken = fromToken.currency, + swapProvider = provider, + bestRatedProviderId = bestRatedProviderId, + isManyProviders = dataState.lastLoadedSwapStates.size > 1, + selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + ) + if (uiState.warnings.any { it is SwapWarning.UnableToCoverFeeWarning }) { + analyticsEventHandler.send( + SwapEvents.NoticeNotEnoughFee( + token = initialCryptoCurrency.symbol, + blockchain = fromToken.currency.network.name, + ), + ) + } + } + is SwapState.EmptyAmountState -> { + uiState = stateBuilder.createQuotesEmptyAmountState( + uiStateHolder = uiState, + emptyAmountState = state, + ) + } + is SwapState.SwapError -> { + if (state.error is DataError.UnknownError) { + singleTaskScheduler.cancelTask() + } + uiState = stateBuilder.createQuotesErrorState( + uiStateHolder = uiState, + swapProvider = provider, + fromToken = state.fromTokenInfo, + toToken = dataState.toCryptoCurrency, + dataError = state.error, + ) + sendErrorAnalyticsEvent(state.error, provider) + } + } + } + + private fun sendErrorAnalyticsEvent(error: DataError, provider: SwapProvider) { + analyticsEventHandler.send( + SwapEvents.NoticeProviderError( + token = initialCryptoCurrency.symbol, + provider = provider, + errorCode = error.code, + ), + ) + } + + private fun updateLoadedQuotes(state: Map): Pair { + val nonEmptyStates = state.filter { it.value !is SwapState.EmptyAmountState } + val selectedSwapProvider = if (nonEmptyStates.isNotEmpty()) { + selectProvider(state) + } else { + null + } + dataState = dataState.copy( + selectedProvider = selectedSwapProvider, + lastLoadedSwapStates = state, + ) + selectedSwapProvider?.let { + return nonEmptyStates.entries.first { it.key == selectedSwapProvider }.toPair() + } + return state.entries.first().toPair() + } + + private fun selectProvider(state: Map): SwapProvider { + val stateSuccess = state.getLastLoadedSuccessStates() + return if (stateSuccess.isNotEmpty()) { + val currentSelected = dataState.selectedProvider + if (currentSelected != null && state.keys.contains(currentSelected)) { + currentSelected + } else { + findBestQuoteProvider(stateSuccess) ?: stateSuccess.keys.first() + } + } else { + state.keys.first() + } + } + + private fun fillLoadedDataState( + state: SwapState.QuotesLoadedState, + permissionState: PermissionDataState, + swapDataModel: SwapDataModel?, + ) { dataState = if (permissionState is PermissionDataState.PermissionReadyForRequest) { dataState.copy( approveDataModel = permissionState.requestApproveData, + approveType = dataState.approveType ?: ApproveType.UNLIMITED, ) } else { dataState.copy( swapDataModel = swapDataModel, - selectedFee = swapDataModel?.fee?.normalFee, + selectedFee = selectDefaultFee(state), ) } } + private fun selectDefaultFee(state: SwapState.QuotesLoadedState): TxFee? { + return dataState.selectedFee + ?: when (val txFee = state.txFee) { + TxFeeState.Empty -> null + is TxFeeState.MultipleFeeState -> { + txFee.normalFee + } + is TxFeeState.SingleFeeState -> { + txFee.fee + } + } + } + + @Suppress("LongMethod") private fun onSwapClick() { singleTaskScheduler.cancelTask() uiState = stateBuilder.createSwapInProgressState(uiState) + val provider = requireNotNull(dataState.selectedProvider) { "Selected provider is null" } + val lastLoadedQuotesState = dataState.lastLoadedSwapStates[provider] as? SwapState.QuotesLoadedState + if (lastLoadedQuotesState == null) { + Timber.e("Last loaded quotes state is null") + return + } viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { swapInteractor.onSwap( + swapProvider = provider, networkId = dataState.networkId, - swapStateData = requireNotNull(dataState.swapDataModel), - currencyToSend = requireNotNull(dataState.fromCurrency), - currencyToGet = requireNotNull(dataState.toCurrency), + swapData = dataState.swapDataModel, + currencyToSend = requireNotNull(dataState.fromCryptoCurrency), + currencyToGet = requireNotNull(dataState.toCryptoCurrency), amountToSwap = requireNotNull(dataState.amount), + includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = requireNotNull(dataState.selectedFee), ) } .onSuccess { when (it) { is TxState.TxSent -> { - uiState = stateBuilder.createSuccessState(uiState, it) { - val txHash = it.txAddress - if (txHash.isNotEmpty()) { - swapRouter.openUrl( - blockchainInteractor.getExplorerTransactionLink( - networkId = dataState.networkId, - txAddress = it.txAddress, - ), + val url = blockchainInteractor.getExplorerTransactionLink( + networkId = dataState.networkId, + txAddress = it.txAddress, + ) + uiState = stateBuilder.createSuccessState( + uiState = uiState, + txState = it, + fromAmount = dataState.amount?.toBigDecimal() ?: BigDecimal.ZERO, + toAmount = dataState.swapDataModel?.toTokenAmount?.value ?: BigDecimal.ZERO, + txUrl = url, + onExploreClick = { + val txHash = it.txAddress + if (txHash.isNotEmpty()) { + swapRouter.openUrl(url) + } + analyticsEventHandler.send( + event = SwapEvents.ButtonExplore(initialCryptoCurrency.symbol), ) - } - } - analyticsEventHandler.send(SwapEvents.SwapInProgressScreen) + }, + onStatusClick = { + val txExternalUrl = it.txExternalUrl + if (!txExternalUrl.isNullOrBlank()) { + swapRouter.openUrl(txExternalUrl) + analyticsEventHandler.send( + event = SwapEvents.ButtonStatus(initialCryptoCurrency.symbol), + ) + } + }, + ) + sendSuccessEvent() + swapRouter.openScreen(SwapNavScreen.Success) } is TxState.UserCancelled -> { @@ -289,31 +496,56 @@ internal class SwapViewModel @Inject constructor( } } .onFailure { + Timber.e(it) startLoadingQuotesFromLastState() makeDefaultAlert() } } } + private fun sendSuccessEvent() { + val provider = dataState.selectedProvider ?: return + val fee = dataState.selectedFee?.feeType ?: return + val sendToken = dataState.fromCryptoCurrency?.currency?.symbol ?: return + val toToken = dataState.toCryptoCurrency?.currency?.symbol ?: return + + analyticsEventHandler.send( + SwapEvents.SwapInProgressScreen( + provider = provider, + commission = fee, + sendToken = sendToken, + receiveToken = toToken, + ), + ) + } + private fun givePermissionsToSwap() { viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { + val feeForPermission = when (val fee = dataState.approveDataModel?.fee) { + TxFeeState.Empty -> error("Fee should not be Empty") + is TxFeeState.MultipleFeeState -> fee.priorityFee + is TxFeeState.SingleFeeState -> fee.fee + null -> error("Fee should not be null") + } swapInteractor.givePermissionToSwap( networkId = dataState.networkId, permissionOptions = PermissionOptions( approveData = requireNotNull(dataState.approveDataModel) { "dataState.approveDataModel might not be null" }, - forTokenContractAddress = (dataState.fromCurrency as? Currency.NonNativeToken)?.contractAddress + forTokenContractAddress = (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Token) + ?.contractAddress ?: "", - fromToken = requireNotNull(dataState.fromCurrency) { + fromToken = requireNotNull(dataState.fromCryptoCurrency?.currency) { "dataState.fromCurrency might not be null" }, - approveType = requireNotNull(uiState.permissionState as? SwapPermissionState.ReadyForRequest) { - "uiState.permissionState should be SwapPermissionState.ReadyForRequest" - }.approveType.toDomainApproveType(), - txFee = requireNotNull(dataState.selectedFee) { - "dataState.selectedFee shouldn't be null" + approveType = requireNotNull(dataState.approveType) { + "uiState.permissionState should not be null" + }.toDomainApproveType(), + txFee = feeForPermission, + spenderAddress = requireNotNull(dataState.approveDataModel?.spenderAddress) { + "dataState.approveDataModel.spenderAddress shouldn't be null" }, ), ) @@ -322,6 +554,7 @@ internal class SwapViewModel @Inject constructor( when (it) { is TxState.TxSent -> { uiState = stateBuilder.loadingPermissionState(uiState) + uiState = stateBuilder.dismissBottomSheet(uiState) } is TxState.UserCancelled -> Unit else -> { @@ -338,80 +571,126 @@ internal class SwapViewModel @Inject constructor( } private fun onSearchEntered(searchQuery: String) { - viewModelScope.launch(dispatchers.main) { - runCatching(dispatchers.io) { - swapInteractor.searchTokens(dataState.networkId, searchQuery) + viewModelScope.launch(dispatchers.io) { + val tokenDataState = dataState.tokensDataState ?: return@launch + val group = if (isOrderReversed) { + tokenDataState.fromGroup + } else { + tokenDataState.toGroup } - .onSuccess { - updateTokensState(it) - } - .onFailure { } + val available = group.available.filter { + it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) + } + val unavailable = group.unavailable.filter { + it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) + } + val filteredTokenDataState = if (isOrderReversed) { + tokenDataState.copy( + fromGroup = tokenDataState.fromGroup.copy( + available = available, + unavailable = unavailable, + ), + ) + } else { + tokenDataState.copy( + toGroup = tokenDataState.toGroup.copy( + available = available, + unavailable = unavailable, + ), + ) + } + updateTokensState(filteredTokenDataState) } } private fun onTokenSelect(id: String) { - val foundToken = swapInteractor.findTokenById(id) - - analyticsEventHandler.send( - event = SwapEvents.SearchTokenClicked(currencySymbol = foundToken?.symbol), - ) + val tokens = dataState.tokensDataState ?: return + val foundToken = if (isOrderReversed) { + tokens.fromGroup.available.firstOrNull { + it.currencyStatus.currency.id.value == id + } + } else { + tokens.toGroup.available.firstOrNull { + it.currencyStatus.currency.id.value == id + } + } + foundToken?.currencyStatus?.currency?.symbol?.let { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it)) + } if (foundToken != null) { - val fromToken: Currency - val toToken: Currency + val fromToken: CryptoCurrencyStatus + val toToken: CryptoCurrencyStatus if (isOrderReversed) { - fromToken = foundToken - toToken = currency + fromToken = foundToken.currencyStatus + toToken = initialCryptoCurrencyStatus } else { - fromToken = currency - toToken = foundToken + fromToken = initialCryptoCurrencyStatus + toToken = foundToken.currencyStatus } dataState = dataState.copy( - fromCurrency = fromToken, - toCurrency = toToken, + fromCryptoCurrency = fromToken, + toCryptoCurrency = toToken, + selectedProvider = null, + ) + startLoadingQuotes( + fromToken = fromToken, + toToken = toToken, + amount = lastAmount.value, + toProvidersList = findSwapProviders(fromToken, toToken), ) - startLoadingQuotes(fromToken, toToken, lastAmount.value) swapRouter.openScreen(SwapNavScreen.Main) + updateTokensState(tokens) } } private fun onChangeCardsClicked() { - val newFromToken = dataState.toCurrency - val newToToken = dataState.fromCurrency + val newFromToken = dataState.toCryptoCurrency + val newToToken = dataState.fromCryptoCurrency if (newFromToken != null && newToToken != null) { dataState = dataState.copy( - fromCurrency = newFromToken, - toCurrency = newToToken, + fromCryptoCurrency = newFromToken, + toCryptoCurrency = newToToken, ) isOrderReversed = !isOrderReversed - val decimals = blockchainInteractor.getTokenDecimals(newFromToken) + val decimals = newFromToken.currency.decimals lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value) uiState = stateBuilder.updateSwapAmount( uiState, inputNumberFormatter.formatWithThousands(lastAmount.value, decimals), ) - startLoadingQuotes(newFromToken, newToToken, lastAmount.value) + startLoadingQuotes( + fromToken = newFromToken, + toToken = newToToken, + amount = lastAmount.value, + toProvidersList = findSwapProviders(newFromToken, newToToken), + ) } } private fun onAmountChanged(value: String) { - val fromToken = dataState.fromCurrency - val toToken = dataState.toCurrency + val fromToken = dataState.fromCryptoCurrency + val toToken = dataState.toCryptoCurrency if (fromToken != null && toToken != null) { - val decimals = blockchainInteractor.getTokenDecimals(fromToken) + 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, toToken, lastAmount.value) + startLoadingQuotes( + fromToken = fromToken, + toToken = toToken, + amount = lastAmount.value, + toProvidersList = findSwapProviders(fromToken, toToken), + ) } } } private fun onMaxAmountClicked() { - dataState.fromCurrency?.let { - val balance = swapInteractor.getTokenBalance(currency.networkId, it) + dataState.fromCryptoCurrency?.let { + val balance = swapInteractor.getTokenBalance(it) onAmountChanged(balance.formatToUIRepresentation()) } } @@ -433,6 +712,7 @@ internal class SwapViewModel @Inject constructor( } } + @Suppress("LongMethod", "CyclomaticComplexMethod") private fun createUiActions(): UiActions { return UiActions( onSearchEntered = { onSearchEntered(it) }, @@ -440,8 +720,8 @@ internal class SwapViewModel @Inject constructor( onAmountChanged = { onAmountChanged(it) }, onSwapClick = { onSwapClick() - val sendTokenSymbol = dataState.fromCurrency?.symbol - val receiveTokenSymbol = dataState.toCurrency?.symbol + val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol + val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol if (sendTokenSymbol != null && receiveTokenSymbol != null) { analyticsEventHandler.send( SwapEvents.ButtonSwapClicked( @@ -453,8 +733,8 @@ internal class SwapViewModel @Inject constructor( }, onGivePermissionClick = { givePermissionsToSwap() - val sendTokenSymbol = dataState.fromCurrency?.symbol - val receiveTokenSymbol = dataState.toCurrency?.symbol + val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol + val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol if (sendTokenSymbol != null && receiveTokenSymbol != null) { analyticsEventHandler.send( SwapEvents.ButtonPermissionApproveClicked( @@ -468,42 +748,194 @@ internal class SwapViewModel @Inject constructor( onChangeCardsClicked() analyticsEventHandler.send(SwapEvents.ButtonSwipeClicked) }, - onBackClicked = { onSearchEntered("") }, + onBackClicked = { + val bottomSheet = uiState.bottomSheetConfig + if (bottomSheet != null && bottomSheet.isShow) { + uiState = stateBuilder.dismissBottomSheet(uiState) + } else { + if (swapRouter.currentScreen == SwapNavScreen.SelectToken) { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = false)) + } + swapRouter.back() + } + onSearchEntered("") + }, onMaxAmountSelected = { onMaxAmountClicked() }, openPermissionBottomSheet = { singleTaskScheduler.cancelTask() analyticsEventHandler.send(SwapEvents.ButtonGivePermissionClicked) - }, - hidePermissionBottomSheet = { - startLoadingQuotesFromLastState() - analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked) + uiState = stateBuilder.showPermissionBottomSheet(uiState) { + startLoadingQuotesFromLastState(isSilent = true) + analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked) + uiState = stateBuilder.dismissBottomSheet(uiState) + } }, onAmountSelected = { onAmountSelected(it) }, onChangeApproveType = { approveType -> uiState = stateBuilder.updateApproveType(uiState, approveType) + dataState = dataState.copy(approveType = approveType) }, - onSelectItemFee = { feeItem -> - dataState = dataState.copy(selectedFee = feeItem.data) - val spendAmount = dataState.amount?.let { amount -> - val fromToken = dataState.fromCurrency ?: return@let null - swapInteractor.getSwapAmountForToken(amount, fromToken) - } ?: dataState.approveDataModel?.fromTokenAmount - spendAmount ?: return@UiActions - val fromToken = dataState.fromCurrency ?: return@UiActions + onClickFee = { + val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL + val txFeeState = dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState + ?: return@UiActions + uiState = stateBuilder.showSelectFeeBottomSheet( + uiState = uiState, + selectedFee = selectedFee, + txFeeState = txFeeState, + ) { + uiState = stateBuilder.dismissBottomSheet(uiState) + } + }, + onSelectFeeType = { + val state = dataState.getCurrentLoadedSwapState() ?: return@UiActions + val fromToken = dataState.fromCryptoCurrency ?: return@UiActions + val amountToSwap = dataState.amount ?: return@UiActions + val selectedProvider = dataState.selectedProvider ?: return@UiActions + uiState = stateBuilder.updateSelectedFeeBottomSheet(uiState, it.feeType) + dataState = dataState.copy(selectedFee = it) viewModelScope.launch(dispatchers.io) { - val isFeeEnough = swapInteractor.checkFeeIsEnough( - fee = feeItem.data.feeValue, - spendAmount = spendAmount, + val updatedState = swapInteractor.updateQuotesStateWithSelectedFee( + state = state, + selectedFee = it.feeType, + fromToken = fromToken, + amountToSwap = amountToSwap, networkId = dataState.networkId, + ) + setupLoadedState(selectedProvider, updatedState, fromToken) + } + }, + onProviderClick = { providerId -> + analyticsEventHandler.send(SwapEvents.ProviderClicked) + val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() + val pricesLowerBest = getPricesLowerBest(states) + val unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates) + uiState = stateBuilder.showSelectProviderBottomSheet( + uiState = uiState, + selectedProviderId = providerId, + pricesLowerBest = pricesLowerBest, + unavailableProviders = unavailableProviders, + providersStates = dataState.lastLoadedSwapStates, + ) { uiState = stateBuilder.dismissBottomSheet(uiState) } + }, + onProviderSelect = { + val provider = findAndSelectProvider(it) + val swapState = dataState.lastLoadedSwapStates[provider] + val fromToken = dataState.fromCryptoCurrency + if (provider != null && swapState != null && fromToken != null) { + analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) + uiState = stateBuilder.updateSelectedProvider(uiState, provider.providerId) + setupLoadedState( + provider = provider, + state = swapState, fromToken = fromToken, ) - uiState = stateBuilder.updateFeeSelectedItem(uiState, feeItem, isFeeEnough) } }, + onBuyClick = { + swapInteractor.getSelectedWallet()?.let { + swapRouter.openTokenDetails(it.walletId, swapInteractor.getNativeToken(dataState.networkId)) + } + }, + onRetryClick = { + startLoadingQuotesFromLastState() + }, ) } + private fun findAndSelectProvider(providerId: String): SwapProvider? { + val selectedProvider = dataState.lastLoadedSwapStates.keys.firstOrNull { it.providerId == providerId } + if (selectedProvider != null) { + dataState = dataState.copy( + selectedProvider = selectedProvider, + ) + } + return selectedProvider + } + + private fun findBestQuoteProvider(state: SuccessLoadedSwapData): SwapProvider? { + // finding best quotes + return state.minByOrNull { + 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, + RoundingMode.HALF_UP, + ) + } else { + BigDecimal.ZERO + } + }?.key + } + + 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 { + 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 + } else { + null + } + } + } + + private fun createSelectedAppCurrencyFlow(): StateFlow { + 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 { + val groupToFind = if (isOrderReversed) { + dataState.tokensDataState?.fromGroup + } else { + dataState.tokensDataState?.toGroup + } ?: return emptyList() + + val idToFind = if (isOrderReversed) { + fromToken.currency.id.value + } else { + toToken.currency.id.value + } + + return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers ?: emptyList() + } + + 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() + } + + private fun getUnavailableProvidersFor(state: Map): List { + return getAllProviders().filterNot { it in state } + } + + private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { + return this + .filter { it.value is SwapState.QuotesLoadedState } + .mapValues { it.value as SwapState.QuotesLoadedState } + } + 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 diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index bc5b2cda79..8c4c106fa0 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) implementation(deps.compose.ui.utils) + implementation(deps.compose.constraintLayout) implementation(deps.arrow.core) implementation(deps.jodatime) @@ -36,6 +37,7 @@ dependencies { implementation(deps.tangem.blockchain) implementation(deps.tangem.card.core) implementation(deps.timber) + implementation(deps.lifecycle.compose) /** DI */ implementation(deps.hilt.android) @@ -48,6 +50,7 @@ dependencies { implementation(projects.core.utils) implementation(projects.core.analytics) implementation(projects.core.analytics.models) + implementation(projects.core.datasource) /** Domain modules */ implementation(projects.domain.appCurrency) @@ -65,7 +68,11 @@ dependencies { implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) + /** Temp dependency to swap domain */ + implementation(projects.features.swap.domain) + /** 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/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 2a25900715..458e547586 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -101,6 +101,7 @@ internal object TokenDetailsPreviewData { ), dialogConfig = null, pendingTxs = persistentListOf(), + swapTxs = persistentListOf(), pullToRefreshConfig = pullToRefreshConfig, bottomSheetConfig = null, isBalanceHidden = false, 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 new file mode 100644 index 0000000000..5e42550ab6 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/SwapTransactionsState.kt @@ -0,0 +1,40 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state + +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 + +internal data class SwapTransactionsState( + val txId: String, + val provider: SwapProvider, + 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 toCryptoCurrencyId: CryptoCurrency.ID, + val toCryptoAmount: String, + val toCryptoSymbol: String, + val toFiatAmount: String, + val toCurrencyIcon: TokenIconState, + val fromCryptoCurrencyId: CryptoCurrency.ID, + val fromCryptoAmount: String, + val fromCryptoSymbol: String, + val fromFiatAmount: String, + val fromCurrencyIcon: TokenIconState, + val onClick: () -> Unit, + val onGoToProviderClick: (String) -> Unit, +) + +internal class ExchangeStatusState( + val status: ExchangeStatus, + val text: TextReference, + val isActive: Boolean, + val isDone: Boolean, +) \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index 15f290adde..f9f5dfcabd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -19,6 +19,7 @@ internal data class TokenDetailsState( val marketPriceBlockState: MarketPriceBlockState, val notifications: ImmutableList, val pendingTxs: PersistentList, + val swapTxs: PersistentList, val txHistoryState: TxHistoryState, val dialogConfig: TokenDetailsDialogConfig?, val pullToRefreshConfig: TokenDetailsPullToRefreshConfig, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotifications.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotifications.kt new file mode 100644 index 0000000000..1da8fd0672 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotifications.kt @@ -0,0 +1,38 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.components + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.tokendetails.impl.R + +@Immutable +internal sealed class ExchangeStatusNotifications(val config: NotificationConfig) { + + data class NeedVerification( + val onGoToProviderClick: () -> Unit, + ) : ExchangeStatusNotifications( + config = NotificationConfig( + title = TextReference.Res(R.string.express_exchange_notification_verification_title), + subtitle = TextReference.Res(R.string.express_exchange_notification_verification_text), + iconResId = R.drawable.ic_alert_triangle_20, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = TextReference.Res(R.string.express_go_to_provider), + onClick = onGoToProviderClick, + ), + ), + ) + + data class Failed( + val onGoToProviderClick: () -> Unit, + ) : ExchangeStatusNotifications( + config = NotificationConfig( + title = TextReference.Res(R.string.express_exchange_notification_failed_title), + subtitle = TextReference.Res(R.string.express_exchange_notification_failed_text), + iconResId = R.drawable.ic_alert_circle_24, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = TextReference.Res(R.string.express_go_to_provider), + onClick = onGoToProviderClick, + ), + ), + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt index e92142e90c..30d410450e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -45,6 +45,7 @@ internal class TokenDetailsSkeletonStateConverter( marketPriceBlockState = MarketPriceBlockState.Loading(value.symbol), notifications = persistentListOf(), pendingTxs = persistentListOf(), + swapTxs = persistentListOf(), txHistoryState = TxHistoryState.Content( contentItems = MutableStateFlow( value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick), 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 997a6e985a..2c2330ba57 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 @@ -22,11 +22,13 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.txhistory.models.TxHistoryItem import com.tangem.domain.txhistory.models.TxHistoryListError import com.tangem.domain.txhistory.models.TxHistoryStateError +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.components.TokenDetailsDialogConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadedTxHistoryConverter import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter.TokenDetailsLoadingTxHistoryModel +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -233,6 +235,18 @@ internal class TokenDetailsStateFactory( return state.copy(notifications = notificationConverter.removeRentInfo(state)) } + fun getStateWithExchangeStatusBottomSheet(swapTxState: SwapTransactionsState): TokenDetailsState { + return currentStateProvider().copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = clickIntents::onDismissBottomSheet, + content = ExchangeStatusBottomSheetConfig( + value = swapTxState, + ), + ), + ) + } + 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 new file mode 100644 index 0000000000..3650c86759 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -0,0 +1,259 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import com.tangem.common.Provider +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.toDateFormat +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent +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.ExchangeStatusState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications +import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents +import com.tangem.features.tokendetails.impl.R +import com.tangem.utils.converter.Converter +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( + private val clickIntents: TokenDetailsClickIntents, + private val cryptoCurrency: CryptoCurrency, + private val analyticsEventsHandlerProvider: Provider, + appCurrencyProvider: Provider, +) : Converter> { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val appCurrency = appCurrencyProvider() + + override fun convert(value: Unit): PersistentList { + return persistentListOf() + } + + fun convert( + savedTransactions: List, + cryptoStatusList: List, + ): PersistentList { + val result = mutableListOf() + + savedTransactions.forEach { swapCurrency -> + val firstStatus = cryptoStatusList.first { it.currency.id.value == swapCurrency.fromCryptoCurrencyId } + val secondStatus = cryptoStatusList.first { it.currency.id.value == swapCurrency.toCryptoCurrencyId } + + val (fromCurrency, toCurrency) = if (swapCurrency.fromCryptoCurrencyId == firstStatus.currency.id.value) { + firstStatus to secondStatus + } else { + secondStatus to firstStatus + } + + swapCurrency.transactions.forEach { transaction -> + val toAmount = transaction.toCryptoAmount + val fromAmount = transaction.fromCryptoAmount + val toFiatAmount = toAmount.multiply(toCurrency.value.fiatRate) + val fromFiatAmount = fromAmount.multiply(fromCurrency.value.fiatRate) + val timestamp = transaction.timestamp + result.add( + SwapTransactionsState( + txId = transaction.txId, + provider = transaction.provider, + txUrl = transaction.status?.txUrl, + 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, + ), + ), + toCryptoCurrencyId = toCurrency.currency.id, + toCryptoAmount = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = toAmount, + cryptoCurrency = toCurrency.currency, + ), + toCryptoSymbol = toCurrency.currency.symbol, + toFiatAmount = getFiatAmount(toFiatAmount), + toCurrencyIcon = iconStateConverter.convert(toCurrency), + fromCryptoCurrencyId = fromCurrency.currency.id, + fromCryptoAmount = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = fromAmount, + cryptoCurrency = fromCurrency.currency, + ), + fromCryptoSymbol = fromCurrency.currency.symbol, + fromFiatAmount = getFiatAmount(fromFiatAmount), + fromCurrencyIcon = iconStateConverter.convert(fromCurrency), + onClick = { clickIntents.onSwapTransactionClick(transaction.txId) }, + onGoToProviderClick = { url -> + analyticsEventsHandlerProvider().send( + TokenExchangeAnalyticsEvent.GoToProviderStatus(cryptoCurrency.symbol), + ) + clickIntents.onGoToProviderClick(url = url) + }, + ), + ) + } + } + 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) } + } + + private fun getFiatAmount(toFiatAmount: BigDecimal): String { + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = toFiatAmount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + + private fun getNotification(status: ExchangeStatus?, txUrl: String?): ExchangeStatusNotifications? { + if (txUrl == null) return null + return when (status) { + ExchangeStatus.Failed -> { + ExchangeStatusNotifications.Failed { + analyticsEventsHandlerProvider().send( + TokenExchangeAnalyticsEvent.GoToProviderFail(cryptoCurrency.symbol), + ) + clickIntents.onGoToProviderClick(txUrl) + } + } + ExchangeStatus.Verifying -> { + ExchangeStatusNotifications.NeedVerification { + analyticsEventsHandlerProvider().send( + TokenExchangeAnalyticsEvent.GoToProviderKYC(cryptoCurrency.symbol), + ) + clickIntents.onGoToProviderClick(txUrl) + } + } + else -> null + } + } + + private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): List { + if (status == null) return emptyList() + val isWaiting = status == ExchangeStatus.New || status == ExchangeStatus.Waiting + val isConfirming = status == ExchangeStatus.Confirming + val isVerifying = status == ExchangeStatus.Verifying + val isExchanging = status == ExchangeStatus.Exchanging + val isFailed = status == ExchangeStatus.Failed + val isSending = status == ExchangeStatus.Sending + val isRefunded = status == ExchangeStatus.Refunded + + val isWaitingDone = !isWaiting + val isConfirmingDone = !isConfirming && isWaitingDone + 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, + ), + ) + } + + private fun waitStep(isNew: Boolean, isNewDone: Boolean) = ExchangeStatusState( + status = ExchangeStatus.New, + text = when { + isNew -> TextReference.Res(R.string.express_exchange_status_receiving_active) + isNewDone -> TextReference.Res(R.string.express_exchange_status_received) + else -> TextReference.Res(R.string.express_exchange_status_receiving) + }, + isActive = isNew, + isDone = isNewDone, + ) + + private fun confirmStep(isConfirming: Boolean, isConfirmingDone: Boolean) = ExchangeStatusState( + status = ExchangeStatus.Confirming, + text = when { + isConfirming -> TextReference.Res(R.string.express_exchange_status_confirming_active) + isConfirmingDone -> TextReference.Res(R.string.express_exchange_status_confirmed) + else -> TextReference.Res(R.string.express_exchange_status_confirming) + }, + isActive = isConfirming, + isDone = isConfirmingDone, + ) + + private fun exchangeStep( + isExchanging: Boolean, + isExchangingDone: Boolean, + isRefunded: Boolean, + hasFailed: Boolean, + isVerifying: Boolean = false, + isFailed: Boolean = false, + ) = when { + isVerifying -> ExchangeStatusState( + status = ExchangeStatus.Verifying, + text = TextReference.Res(R.string.express_exchange_status_verifying), + isActive = true, + isDone = false, + ) + hasFailed || isFailed || isRefunded -> ExchangeStatusState( + status = ExchangeStatus.Failed, + text = TextReference.Res(R.string.express_exchange_status_failed), + isActive = isFailed || isRefunded, + isDone = isRefunded, + ) + else -> ExchangeStatusState( + status = ExchangeStatus.Exchanging, + text = when { + isExchanging -> TextReference.Res(R.string.express_exchange_status_exchanging_active) + isExchangingDone -> TextReference.Res(R.string.express_exchange_status_exchanged) + else -> TextReference.Res(R.string.express_exchange_status_exchanging) + }, + isActive = isExchanging, + isDone = isExchangingDone, + ) + } + + private fun sendStep(isSending: Boolean, isSendingDone: Boolean, isRefunded: Boolean, hasFailed: Boolean) = when { + hasFailed || isRefunded -> ExchangeStatusState( + status = ExchangeStatus.Refunded, + text = TextReference.Res(R.string.express_exchange_status_refunded), + isActive = false, + isDone = isRefunded, + ) + else -> ExchangeStatusState( + status = ExchangeStatus.Sending, + text = when { + isSending -> TextReference.Res(R.string.express_exchange_status_sending_active) + isSendingDone -> TextReference.Res(R.string.express_exchange_status_sent) + else -> TextReference.Res(R.string.express_exchange_status_sending) + }, + isActive = isSending, + isDone = isSendingDone, + ) + } +} \ No newline at end of file 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 9b4e73ef9a..57f5e40688 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 @@ -2,7 +2,9 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.ExperimentalMaterialApi @@ -40,6 +42,9 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsDialogs import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsTopAppBar import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheet +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.swapTransactionsItems // TODO: Split to blocks [REDACTED_JIRA] @Suppress("LongMethod") @@ -117,6 +122,11 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) { ) } + swapTransactionsItems( + state.swapTxs, + itemModifier, + ) + txHistoryItems( state = state.txHistoryState, isBalanceHidden = state.isBalanceHidden, @@ -141,6 +151,9 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) { is ChooseAddressBottomSheetConfig -> { ChooseAddressBottomSheet(config = config) } + is ExchangeStatusBottomSheetConfig -> { + ExchangeStatusBottomSheet(config = config) + } } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index 8280f9aeab..f9911ae521 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -39,7 +39,7 @@ internal fun TokenDetailsBalanceBlock( start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, ), - text = stringResource(id = R.string.onboarding_balance_title), + text = stringResource(id = R.string.common_balance_title), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, maxLines = 1, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeEstimate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeEstimate.kt new file mode 100644 index 0000000000..6aee05217a --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeEstimate.kt @@ -0,0 +1,71 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.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.stringResource +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.components.inputrow.InputRowApprox +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tokendetails.impl.R + +@Suppress("LongParameterList") +@Composable +internal fun ExchangeEstimate( + timestamp: TextReference, + fromTokenIconState: TokenIconState, + toTokenIconState: TokenIconState, + fromCryptoAmount: TextReference, + fromCryptoSymbol: String, + toCryptoAmount: TextReference, + toCryptoSymbol: String, + fromFiatAmount: TextReference, + toFiatAmount: TextReference, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing14, + bottom = TangemTheme.dimens.spacing2, + ), + ) { + Text( + text = stringResource(id = R.string.express_estimated_amount), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + Text( + text = timestamp.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + InputRowApprox( + leftIcon = fromTokenIconState, + leftTitle = fromCryptoAmount, + leftSubtitle = fromFiatAmount, + leftTitleEllipsisOffset = fromCryptoSymbol.length, + rightIcon = toTokenIconState, + rightTitle = toCryptoAmount, + rightSubtitle = toFiatAmount, + rightTitleEllipsisOffset = toCryptoSymbol.length, + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeProvider.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeProvider.kt new file mode 100644 index 0000000000..26c7393a85 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeProvider.kt @@ -0,0 +1,41 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.R +import com.tangem.core.ui.components.inputrow.InputRowBestRate +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun ExchangeProvider(providerName: TextReference, providerType: TextReference, imageUrl: String) { + Column( + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) { + Text( + text = stringResource(id = R.string.express_provider), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + ), + ) + InputRowBestRate( + imageUrl = imageUrl, + title = providerName, + titleExtra = providerType, + subtitle = TextReference.Res(R.string.express_floating_rate), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..9d44f11a9e --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBlock.kt @@ -0,0 +1,211 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material3.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.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 + +@Composable +internal fun ExchangeStatusBlock( + statuses: MutableStateFlow>, + showLink: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val statusValues = statuses.collectAsStateWithLifecycle() + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .padding( + vertical = TangemTheme.dimens.spacing14, + horizontal = TangemTheme.dimens.spacing12, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing16), + ) { + Text( + text = stringResource(id = R.string.express_exchange_status_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerWMax() + AnimatedVisibility(visible = showLink) { + Row( + modifier = Modifier.clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_top_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.spacing16) + .padding(end = TangemTheme.dimens.spacing2), + ) + Text( + text = stringResource(id = R.string.express_go_to_provider), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + + statusValues.value.forEachIndexed { index, item -> + ExchangeStatusStep( + stepStatus = item, + isLast = index == statusValues.value.lastIndex, + ) + } + } +} + +@Composable +private fun ExchangeStatusStep( + stepStatus: ExchangeStatusState, + modifier: Modifier = Modifier, + isLast: Boolean = false, +) { + Row(modifier = modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + targetState = stepStatus, + label = "Exchange Step Change Success", + modifier = Modifier + .size(TangemTheme.dimens.size20), + ) { + when { + it.status == ExchangeStatus.Failed -> ExchangeStep( + iconRes = R.drawable.ic_close_24, + color = TangemTheme.colors.icon.warning, + isDone = it.isDone, + ) + it.status == ExchangeStatus.Verifying -> ExchangeStep( + iconRes = R.drawable.ic_exclamation_24, + color = TangemTheme.colors.icon.attention, + isDone = it.isDone, + ) + it.isDone -> ExchangeStep( + iconRes = R.drawable.ic_check_24, + color = TangemTheme.colors.icon.primary1, + isDone = true, + ) + it.isActive -> ExchangeStepInProgress() + else -> ExchangeStepDefault() + } + } + if (!isLast) { + ExchangeStepSeparator() + } + } + ExchangeStatusStepText(stepStatus) + } +} + +@Composable +private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) { + val textColor = when { + 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 + !stepStatus.isActive -> TangemTheme.colors.text.disabled + else -> TangemTheme.colors.text.primary1 + } + + Text( + text = stepStatus.text.resolveReference(), + style = TangemTheme.typography.body2, + color = textColor, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12), + ) +} + +@Composable +private fun ExchangeStepDefault() { + Box( + modifier = Modifier + .border( + width = TangemTheme.dimens.size1_5, + color = TangemTheme.colors.field.focused, + shape = CircleShape, + ) + .padding(TangemTheme.dimens.spacing2), + ) +} + +@Composable +private fun ExchangeStep(color: Color, @DrawableRes iconRes: Int, isDone: Boolean) { + val (iconColor, borderColor) = if (isDone) { + TangemTheme.colors.icon.primary1 to TangemTheme.colors.field.focused + } else { + color to color + } + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = iconColor, + modifier = Modifier + .border( + width = TangemTheme.dimens.size1_5, + color = borderColor, + shape = CircleShape, + ) + .padding(TangemTheme.dimens.spacing2), + ) +} + +@Composable +private fun ExchangeStepInProgress() { + CircularProgressIndicator( + color = TangemTheme.colors.icon.primary1, + strokeWidth = TangemTheme.dimens.size2, + modifier = Modifier + .padding(TangemTheme.dimens.spacing2) + .size(TangemTheme.dimens.size14), + ) +} + +@Composable +private fun ExchangeStepSeparator() { + Box( + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing2) + .size( + width = TangemTheme.dimens.size1_5, + height = TangemTheme.dimens.size10, + ) + .background( + color = TangemTheme.colors.field.focused, + shape = CircleShape, + ), + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f8850738e2 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBottomSheet.kt @@ -0,0 +1,107 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +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 com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH10 +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState + +@Composable +internal fun ExchangeStatusBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + contentColor = TangemTheme.colors.background.tertiary, + ) { content: ExchangeStatusBottomSheetConfig -> + ExchangeStatusBottomSheetContent(content = content) + } +} + +@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), + ) { + SpacerH10() + Text( + text = stringResource(id = R.string.express_exchange_status_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.align(CenterHorizontally), + ) + SpacerH10() + Text( + text = stringResource(id = R.string.express_exchange_status_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier + .align(CenterHorizontally), + ) + SpacerH16() + ExchangeEstimate( + timestamp = config.timestamp, + fromTokenIconState = config.fromCurrencyIcon, + toTokenIconState = config.toCurrencyIcon, + fromCryptoAmount = TextReference.Str(config.fromCryptoAmount), + fromCryptoSymbol = config.fromCryptoSymbol, + toCryptoAmount = TextReference.Str(config.toCryptoAmount), + toCryptoSymbol = config.toCryptoSymbol, + fromFiatAmount = TextReference.Str(config.fromFiatAmount), + toFiatAmount = TextReference.Str(config.toFiatAmount), + ) + SpacerH12() + ExchangeProvider( + providerName = TextReference.Str(config.provider.name), + providerType = TextReference.Str(config.provider.type.name), + imageUrl = config.provider.imageLarge, + ) + SpacerH12() + ExchangeStatusBlock( + statuses = config.statuses, + showLink = notification.value == null && config.txUrl != null, + onClick = { config.onGoToProviderClick(config.txUrl.orEmpty()) }, + ) + AnimatedContent( + targetState = notification.value, + label = "Exchange Status Notification Change", + ) { + it?.let { + val tint = when (status.value) { + ExchangeStatus.Verifying -> TangemTheme.colors.icon.attention + ExchangeStatus.Failed -> TangemTheme.colors.icon.warning + else -> null + } + Notification( + config = it.config, + iconTint = tint, + containerColor = TangemTheme.colors.background.action, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + ) + } + } + SpacerH24() + } +} + +internal data class ExchangeStatusBottomSheetConfig( + val value: SwapTransactionsState, +) : TangemBottomSheetConfigContent \ No newline at end of file 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 new file mode 100644 index 0000000000..c73e3e3051 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusItems.kt @@ -0,0 +1,242 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +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.lazy.LazyListScope +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.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 +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState +import com.tangem.features.tokendetails.impl.R +import kotlinx.collections.immutable.PersistentList + +@OptIn(ExperimentalFoundationApi::class) +internal fun LazyListScope.swapTransactionsItems( + swapTxs: PersistentList, + modifier: Modifier = Modifier, +) { + if (swapTxs.isNotEmpty()) { + items( + count = swapTxs.size, + key = { swapTxs[it].txId }, + contentType = { swapTxs[it]::class.java }, + ) { + val item = swapTxs[it] + val status = item.activeStatus.collectAsStateWithLifecycle() + val (iconRes, tint) = when (status.value) { + 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 + else -> null to null + } + + ExchangeStatusItem( + providerName = item.provider.name, + fromTokenIconState = item.fromCurrencyIcon, + toTokenIconState = item.toCurrencyIcon, + fromAmount = item.fromCryptoAmount, + fromSymbol = item.fromCryptoSymbol, + toSymbol = item.toCryptoSymbol, + onClick = item.onClick, + infoIconRes = iconRes, + infoIconTint = tint, + modifier = modifier.animateItemPlacement(), + ) + } + } +} + +@Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod", "LongParameterList") +@Composable +private fun ExchangeStatusItem( + providerName: String, + fromTokenIconState: TokenIconState, + toTokenIconState: TokenIconState, + fromAmount: String, + fromSymbol: String, + toSymbol: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + @DrawableRes infoIconRes: Int? = null, + infoIconTint: Color? = null, +) { + ConstraintLayout( + modifier = modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary) + .clickable { onClick() } + .padding(TangemTheme.dimens.spacing12), + ) { + val (titleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = createRefs() + val padding6 = TangemTheme.dimens.spacing6 + + Text( + text = stringResource(id = R.string.express_exchange_by, providerName), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.constrainAs(titleRef) { + start.linkTo(parent.start) + top.linkTo(parent.top) + }, + ) + TokenIcon( + state = fromTokenIconState, + shouldDisplayNetwork = false, + modifier = Modifier + .size(TangemTheme.dimens.size20) + .constrainAs(fromIconRef) { + start.linkTo(parent.start) + top.linkTo(titleRef.bottom, padding6) + bottom.linkTo(parent.bottom) + }, + ) + EllipsisText( + text = fromAmount, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ellipsis = TextEllipsis.OffsetEnd(fromSymbol.length), + modifier = Modifier.constrainAs(fromRef) { + start.linkTo(fromIconRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(swapIconRef.start) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints.atMostWrapContent + }, + ) + Icon( + painter = painterResource(id = R.drawable.ic_forward_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.size12) + .constrainAs(swapIconRef) { + start.linkTo(fromRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(toIconRef.start) + bottom.linkTo(parent.bottom) + }, + ) + TokenIcon( + state = toTokenIconState, + shouldDisplayNetwork = false, + modifier = Modifier + .size(TangemTheme.dimens.size20) + .constrainAs(toIconRef) { + start.linkTo(swapIconRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(toRef.start) + bottom.linkTo(parent.bottom) + }, + ) + Text( + text = toSymbol, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.constrainAs(toRef) { + start.linkTo(toIconRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(infoIconRef.start, padding6, padding6) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints.atLeastWrapContent + }, + ) + Icon( + painter = painterResource(id = infoIconRes ?: R.drawable.ic_alert_triangle_20), + contentDescription = null, + tint = infoIconTint ?: TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.size20) + .constrainAs(infoIconRef) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(iconRef.start) + visibility = if (infoIconRes == null) { + Visibility.Gone + } else { + Visibility.Visible + } + }, + ) + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.size24) + .constrainAs(iconRef) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + ) + } +} + +//region Preview +@Preview +@Composable +private fun ExchangeStatusItemPreview_Light( + @PreviewParameter(ExchangeStatusItemsPreviewParameterProvider::class) amount: String, +) { + TangemTheme { + ExchangeStatusItem( + providerName = "ChangeNow", + fromTokenIconState = TokenIconState.Loading, + toTokenIconState = TokenIconState.Loading, + fromAmount = amount, + fromSymbol = "USDT", + toSymbol = "USDT", + onClick = {}, + infoIconRes = null, + infoIconTint = null, + ) + } +} + +@Preview +@Composable +private fun ExchangeStatusItemPreview_Dark( + @PreviewParameter(ExchangeStatusItemsPreviewParameterProvider::class) amount: String, +) { + TangemTheme(isDark = true) { + ExchangeStatusItem( + providerName = "ChangeNow", + fromTokenIconState = TokenIconState.Loading, + toTokenIconState = TokenIconState.Loading, + fromAmount = amount, + fromSymbol = "USDT", + toSymbol = "USDT", + onClick = {}, + infoIconRes = null, + infoIconTint = null, + ) + } +} + +private class ExchangeStatusItemsPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + "1111111111111111111111111111 USDT", + "11111 USDT", + ) +} +//endregion \ No newline at end of file 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 new file mode 100644 index 0000000000..13445dc76d --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt @@ -0,0 +1,162 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels + +import arrow.core.getOrElse +import com.tangem.common.Provider +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.datasource.local.swaptx.ExchangeAnalyticsStatus +import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore +import com.tangem.domain.appcurrency.model.AppCurrency +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.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.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.factory.TokenDetailsSwapTransactionsStateConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +@Suppress("LongParameterList") +internal class ExchangeStatusFactory( + private val swapTransactionRepository: SwapTransactionRepository, + private val swapRepository: SwapRepository, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + private val swapTransactionStatusStore: SwapTransactionStatusStore, + private val dispatchers: CoroutineDispatcherProvider, + private val clickIntents: TokenDetailsClickIntents, + private val appCurrencyProvider: Provider, + private val analyticsEventsHandlerProvider: Provider, + private val userWalletId: UserWalletId, + private val cryptoCurrency: CryptoCurrency, +) { + + private val swapTransactionsStateConverter by lazy { + TokenDetailsSwapTransactionsStateConverter( + clickIntents = clickIntents, + cryptoCurrency = cryptoCurrency, + appCurrencyProvider = appCurrencyProvider, + analyticsEventsHandlerProvider = analyticsEventsHandlerProvider, + ) + } + + operator fun invoke() = combine( + flow = swapTransactionRepository.getTransactions(userWalletId, cryptoCurrency.id), + flow2 = getWalletCryptoCurrencies().conflate(), + ) { savedTransactions, cryptoCurrenciesStatusList -> + getExchangeStatusState( + savedTransactions = savedTransactions, + cryptoCurrencyStatusList = cryptoCurrenciesStatusList, + ) + } + + private fun getWalletCryptoCurrencies() = flow { + val selectedWallet = getSelectedWalletSyncUseCase().fold( + ifLeft = { null }, + ifRight = { it }, + ) + requireNotNull(selectedWallet) { "No selected wallet" } + + val cryptoCurrenciesList = getMultiCryptoCurrencyStatusUseCase(selectedWallet.walletId) + .getOrElse { emptyList() } + + emit(cryptoCurrenciesList) + } + + 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) + } + } + .awaitAll() + .filterNotNull() + .toPersistentList() + } + + private suspend fun getExchangeStatus(txId: String): ExchangeStatusModel? { + return swapRepository.getExchangeStatus(txId) + .fold( + ifLeft = { null }, + ifRight = { + sendStatusUpdateAnalytics(it) + it + }, + ) + } + + private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel) { + val txId = statusModel.txId ?: return + val status = toAnalyticStatus(statusModel.status) ?: return + val savedStatus = swapTransactionStatusStore.getTransactionStatus(txId) + + if (savedStatus != status) { + analyticsEventsHandlerProvider().send( + TokenExchangeAnalyticsEvent.CexTxStatusChanged(cryptoCurrency.symbol, status.value), + ) + swapTransactionStatusStore.setTransactionStatus(txId, status) + } + } + + private fun getExchangeStatusState( + savedTransactions: List?, + cryptoCurrencyStatusList: List, + ): PersistentList { + if (savedTransactions == null || cryptoCurrencyStatusList.isEmpty()) { + return persistentListOf() + } + + return swapTransactionsStateConverter.convert( + savedTransactions = savedTransactions, + cryptoStatusList = cryptoCurrencyStatusList, + ) + } + + 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 toAnalyticStatus(status: ExchangeStatus?): ExchangeAnalyticsStatus? { + return when (status) { + ExchangeStatus.New, + ExchangeStatus.Waiting, + ExchangeStatus.Sending, + ExchangeStatus.Confirming, + ExchangeStatus.Exchanging, + -> ExchangeAnalyticsStatus.InProgress + ExchangeStatus.Verifying -> ExchangeAnalyticsStatus.KYC + ExchangeStatus.Failed -> ExchangeAnalyticsStatus.Fail + ExchangeStatus.Finished -> ExchangeAnalyticsStatus.Done + ExchangeStatus.Refunded -> ExchangeAnalyticsStatus.Refunded + else -> null + } + } +} \ No newline at end of file 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 992915899c..95093f7fdb 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 @@ -38,4 +38,8 @@ interface TokenDetailsClickIntents { fun onDismissBottomSheet() fun onCloseRentInfoNotification() + + fun onSwapTransactionClick(txId: String) + + fun onGoToProviderClick(url: String) } \ 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 eeb8b108b0..1a718c9846 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 @@ -13,6 +13,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.resourceReference +import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -24,6 +25,7 @@ import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus 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.txhistory.usecase.GetExplorerTransactionUrlUseCase @@ -31,16 +33,19 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase 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.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.features.tokendetails.impl.R import com.tangem.features.tokendetails.navigation.TokenDetailsRouter -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.coroutines.* import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.PersistentList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* @@ -50,7 +55,7 @@ import java.math.BigDecimal import javax.inject.Inject import kotlin.properties.Delegates -@Suppress("LongParameterList", "LargeClass") +@Suppress("LongParameterList", "LargeClass", "TooManyFunctions") @HiltViewModel internal class TokenDetailsViewModel @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, @@ -67,6 +72,11 @@ internal class TokenDetailsViewModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getCurrencyWarningsUseCase: GetCurrencyWarningsUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + private val swapRepository: SwapRepository, + private val swapTransactionRepository: SwapTransactionRepository, + private val swapTransactionStatusStore: SwapTransactionStatusStore, private val isDemoCardUseCase: IsDemoCardUseCase, private val reduxStateHolder: ReduxStateHolder, private val analyticsEventsHandler: AnalyticsEventHandler, @@ -85,8 +95,11 @@ internal class TokenDetailsViewModel @Inject constructor( private val marketPriceJobHolder = JobHolder() private val refreshStateJobHolder = JobHolder() private val warningsJobHolder = JobHolder() + private val swapTxJobHolder = JobHolder() private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null + private var swapTxStatusTaskScheduler = SingleTaskScheduler>() + private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() private val stateFactory = TokenDetailsStateFactory( @@ -97,6 +110,22 @@ internal class TokenDetailsViewModel @Inject constructor( decimals = cryptoCurrency.decimals, ) + private val exchangeStatusFactory by lazy { + ExchangeStatusFactory( + swapTransactionRepository = swapTransactionRepository, + swapRepository = swapRepository, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + swapTransactionStatusStore = swapTransactionStatusStore, + dispatchers = dispatchers, + clickIntents = this, + appCurrencyProvider = Provider { selectedAppCurrencyFlow.value }, + analyticsEventsHandlerProvider = Provider { analyticsEventsHandler }, + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + } + var uiState: TokenDetailsState by mutableStateOf(stateFactory.getInitialState(cryptoCurrency)) private set @@ -108,8 +137,14 @@ internal class TokenDetailsViewModel @Inject constructor( handleBalanceHiding(owner) } + override fun onCleared() { + swapTxStatusTaskScheduler.cancelTask() + super.onCleared() + } + private fun updateContent() { subscribeOnCurrencyStatusUpdates() + subscribeOnExchangeTransactionsUpdates() updateTxHistory(refresh = false, showItemsLoading = true) } @@ -176,6 +211,33 @@ internal class TokenDetailsViewModel @Inject constructor( } } + private fun subscribeOnExchangeTransactionsUpdates() { + viewModelScope.launch(dispatchers.io) { + swapTxStatusTaskScheduler.cancelTask() + exchangeStatusFactory.invoke() + .onEach { swapTxs -> + swapTxStatusTaskScheduler.scheduleTask( + viewModelScope, + PeriodicTask( + delay = EXCHANGE_STATUS_UPDATE_DELAY, + task = { + runCatching(dispatchers.io) { + exchangeStatusFactory.updateSwapTxStatuses(swapTxs) + } + }, + onSuccess = { updatedTxs -> + uiState = uiState.copy(swapTxs = updatedTxs) + }, + onError = {}, + ), + ) + } + .flowOn(dispatchers.io) + .launchIn(viewModelScope) + .saveIn(swapTxJobHolder) + } + } + /** * @param refresh - invalidate cache and get data from remote * @param showItemsLoading - show loading items placeholder. @@ -460,6 +522,7 @@ internal class TokenDetailsViewModel @Inject constructor( refresh = true, showItemsLoading = uiState.txHistoryState !is TxHistoryState.Content, ) + subscribeOnExchangeTransactionsUpdates() }, ).awaitAll() uiState = stateFactory.getRefreshedState() @@ -473,4 +536,18 @@ internal class TokenDetailsViewModel @Inject constructor( override fun onCloseRentInfoNotification() { uiState = stateFactory.getStateWithRemovedRentNotification() } + + override fun onSwapTransactionClick(txId: String) { + val swapTxState = uiState.swapTxs.first { it.txId == txId } + analyticsEventsHandler.send(TokenExchangeAnalyticsEvent.CexTxStatusOpened(cryptoCurrency.symbol)) + uiState = stateFactory.getStateWithExchangeStatusBottomSheet(swapTxState) + } + + override fun onGoToProviderClick(url: String) { + router.openUrl(url) + } + + private companion object { + const val EXCHANGE_STATUS_UPDATE_DELAY = 10_000L + } } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyAmount.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyAmount.kt index aa4c58b0f3..4a877ccca3 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyAmount.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyAmount.kt @@ -13,4 +13,11 @@ data class ProxyAmount( val currencySymbol: String, var value: BigDecimal, val decimals: Int, -) \ No newline at end of file +) { + + companion object { + fun empty(): ProxyAmount { + return ProxyAmount("", BigDecimal.ZERO, 0) + } + } +} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt index be151b6dac..b8b3dfa295 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt @@ -1,7 +1,14 @@ package com.tangem.lib.crypto.models -data class ProxyFees( - val minFee: ProxyFee, - val normalFee: ProxyFee, - val priorityFee: ProxyFee, -) \ No newline at end of file +sealed class ProxyFees { + + data class MultipleFees( + val minFee: ProxyFee, + val normalFee: ProxyFee, + val priorityFee: ProxyFee, + ) : ProxyFees() + + data class SingleFee( + val singleFee: ProxyFee, + ) : ProxyFees() +} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/SwapTxData.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/SwapTxData.kt index 723169c990..4732f49fcb 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/SwapTxData.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/SwapTxData.kt @@ -13,6 +13,7 @@ import java.math.BigDecimal * @property amountToSend amount of tx * @property currencyToSend currency for tx */ +// TODO split to cex,dex data class SwapTxData( val networkId: String, val feeAmount: BigDecimal,