Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-07 15:57:44 +03:00
commit 6303e0b5af
168 changed files with 8245 additions and 2347 deletions

View file

@ -36,7 +36,6 @@ internal object TransactionDomainModule {
isDemoCardUseCase = isDemoCardUseCase,
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade,
)
}
}

View file

@ -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<Toolbar>(R.id.toolbar)?.setNavigationOnClickListener {
handler.handleOnBackPressed()

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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,

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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) {

View file

@ -16,7 +16,7 @@ internal fun <T : Any> Response<T>.toSafeApiResponse(): ApiResponse<T> {
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)

View file

@ -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<List<Asset>>
@ -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<ExchangeQuoteResponse>
@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<ExchangeDataResponse>
@GET("exchange-result")
suspend fun getExchangeResults(@Query("txId") txId: String): ApiResponse<ExchangeResultsResponse>
@GET("exchange-status")
suspend fun getExchangeStatus(@Query("txId") txId: String): ApiResponse<ExchangeStatusResponse>
}

View file

@ -0,0 +1,5 @@
package com.tangem.datasource.api.express.models
object TangemExpressValues {
const val EMPTY_CONTRACT_ADDRESS_VALUE = "0"
}

View file

@ -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<LeastTokenInfo>?,
@Json(name = "tokensList") val tokensList: List<LeastTokenInfo>?,
)

View file

@ -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,
)

View file

@ -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,

View file

@ -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 {

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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?,
)

View file

@ -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<RateType>,
)
enum class RateType {

View file

@ -0,0 +1,6 @@
package com.tangem.datasource.api.express.models.response
class SwapPairsWithProviders(
val swapPair: List<SwapPair>,
val providers: List<ExchangeProvider>,
)

View file

@ -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())
}
}

View file

@ -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

View file

@ -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(),
)
}
}

View file

@ -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<Preferences> */

View file

@ -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<ExchangeAnalyticsStatus>,
) : SwapTransactionStatusStore, StringKeyDataStore<ExchangeAnalyticsStatus> by dataStore {
override suspend fun getTransactionStatus(txId: String) = getSyncOrNull(txId)
override suspend fun setTransactionStatus(txId: String, status: ExchangeAnalyticsStatus) = store(txId, status)
}

View file

@ -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"),
}

View file

@ -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<Asset>?
suspend fun store(userWalletId: UserWalletId, item: List<Asset>)
}

View file

@ -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<CoinsResponse>,
) : UserMarketCoinsStore {
internal class DefaultAssetsStore(
private val dataStore: StringKeyDataStore<List<Asset>>,
) : AssetsStore {
override suspend fun getSyncOrNull(userWalletId: UserWalletId): CoinsResponse? {
override suspend fun getSyncOrNull(userWalletId: UserWalletId): List<Asset>? {
return dataStore.getSyncOrNull(userWalletId.stringValue)
}
override suspend fun store(userWalletId: UserWalletId, item: CoinsResponse) {
override suspend fun store(userWalletId: UserWalletId, item: List<Asset>) {
dataStore.store(userWalletId.stringValue, item)
}
}

View file

@ -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)
}

View file

@ -13,6 +13,7 @@
<string name="common_save_changes">Änderungen speichern</string>
<string name="common_send">Absenden</string>
<string name="common_success">Erfolg</string>
<string name="common_fee_label">Gebühr</string>
<string name="details_manage_security_access_code">Zugangscode</string>
<string name="details_manage_security_access_code_description">Sie müssen den richtigen Zugangscode eingeben, bevor Sie die Karte scannen.</string>
<string name="details_manage_security_long_tap">Langes Tippen</string>
@ -40,7 +41,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">inkl. Gebühr</string>
<string name="send_fee_label">Gebühr</string>
<string name="send_fee_picker_low">Niedrig</string>
<string name="send_fee_picker_normal">Normal</string>
<string name="send_fee_picker_priority">Priorität</string>

View file

@ -13,6 +13,7 @@
<string name="common_save_changes">Sauvegarder les modifications</string>
<string name="common_send">Envoyer</string>
<string name="common_success">Avec succès</string>
<string name="common_fee_label">Commissions</string>
<string name="details_manage_security_access_code">Code d\'accès</string>
<string name="details_manage_security_access_code_description">Vous devrez entrer le mot de passe correct avant de scanner la carte</string>
<string name="details_manage_security_long_tap">Tenez la carte fermement</string>
@ -40,7 +41,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">Inclure les commissions</string>
<string name="send_fee_label">Commissions</string>
<string name="send_fee_picker_low">Bas</string>
<string name="send_fee_picker_normal">Normal</string>
<string name="send_fee_picker_priority">Priorité</string>

View file

@ -13,6 +13,7 @@
<string name="common_save_changes">Mantieni le modifiche</string>
<string name="common_send">Invia</string>
<string name="common_success">Con successo</string>
<string name="common_fee_label">Commissione</string>
<string name="details_manage_security_access_code">Codice di accesso</string>
<string name="details_manage_security_access_code_description">Prima di scansionare la carta sarà necessario inserire il codice di accesso corretto</string>
<string name="details_manage_security_long_tap">Mantenimento della carta</string>
@ -40,7 +41,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">Includi commissione</string>
<string name="send_fee_label">Commissione</string>
<string name="send_fee_picker_low">Insufficiente</string>
<string name="send_fee_picker_normal">Normale</string>
<string name="send_fee_picker_priority">Prioritario</string>

View file

@ -66,6 +66,7 @@
<string name="common_approval">Одобрение</string>
<string name="common_attention">Внимание</string>
<string name="common_balance">Баланс: %s</string>
<string name="common_balance_title">Баланс</string>
<string name="common_biometric_authentication">биометрическую аутентификацию</string>
<string name="common_biometrics">биометрией</string>
<string name="common_buy">Купить</string>
@ -85,9 +86,11 @@
<string name="common_enabled">Включено</string>
<string name="common_error">Ошибка</string>
<string name="common_exchange">Обменять</string>
<string name="common_explore_transaction_history">Посмотреть историю транзакций</string>
<string name="common_explore">Обозреватель</string>
<string name="common_explore_history">Посмотреть историю</string>
<string name="common_explore_transaction_history">Посмотреть историю транзакций</string>
<string name="common_explorer">Обозреватель</string>
<string name="common_fee_label">Комиссия</string>
<string name="common_fee_selector_footer">Сетевые комиссии за транзакции используются для поддержки безопасности сети, поощрения валидаторов, выделения ресурсов и определения приоритета транзакции.</string>
<string name="common_fee_selector_option_custom">Свое</string>
<string name="common_fee_selector_option_fast">Быстро</string>
@ -191,9 +194,19 @@
<string name="exchange_tokens_available_tokens_header">Мои токены</string>
<string name="exchange_tokens_empty_tokens">У вас нет добавленных токенов. Добавьте токены для обмена</string>
<string name="exchange_tokens_unavailable_tokens_header">Недоступен для обмена с %s</string>
<string name="express_cex_status_button_title">Статус</string>
<string name="express_choose_providers_subtitle">Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами</string>
<string name="express_choose_providers_title">Выберите провайдера</string>
<string name="express_exchange_notification_failed_text">Чтобы узнать причину, посетите сайт провайдера</string>
<string name="express_exchange_notification_failed_title">Чтобы вернуть ваши деньги, посетите сайт провайдера</string>
<string name="express_exchange_notification_verification_text">Посетите сайт провайдера для проверки</string>
<string name="express_exchange_notification_verification_title">Провайдер запрашивает прохождение верификации</string>
<string name="express_exchange_token_list_subtitle">Список токенов в вашем кошельке</string>
<string name="express_fetch_best_rates">Получение наилучших курсов...</string>
<string name="express_provider">Провайдер</string>
<string name="express_provider_best_rate">Лучший курс</string>
<string name="express_provider_min_amount">Доступно с %s</string>
<string name="express_provider_not_available">Недоступно для этой пары</string>
<string name="express_provider_permission_needed">Требуется разрешение</string>
<string name="feedback_data_collection_message">Информация ниже не является обязательной. Вы можете стереть её, если хотите.</string>
<string name="feedback_preface_rate_negative">Расскажите, каких функций вам не хватает, и мы постараемся вам помочь.</string>
@ -267,7 +280,6 @@
<string name="onboarding_access_codes_doesnt_match">Введенные коды доступа не совпадают</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">Вы добавили одну резервную карту. После того, как процесс будет завершен, Вы больше не сможете добавить карт. Если у Вас есть еще одна карта, добавьте ее в резервную копию. Хотите продолжить?</string>
<string name="onboarding_backup_exit_warning">Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас.</string>
<string name="onboarding_balance_title">Баланс</string>
<string name="onboarding_button_add_backup_card">Добавить резервную карту</string>
<string name="onboarding_button_backup_card_format">Сканировать карту #%d</string>
<string name="onboarding_button_backup_now">Создать резервную копию</string>
@ -418,7 +430,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">Включая комиссию</string>
<string name="send_fee_label">Комиссия</string>
<string name="send_fee_picker_low">Низкая</string>
<string name="send_fee_picker_normal">Нормальная</string>
<string name="send_fee_picker_priority">Приоритетная</string>
@ -629,12 +640,18 @@
<string name="warning_button_like_it">Нравится</string>
<string name="warning_button_ok">Понятно!</string>
<string name="warning_button_really_cool">Очень круто!</string>
<string name="warning_button_refresh">Обновить</string>
<string name="warning_demo_mode_message">Вы находитесь в режиме демо</string>
<string name="warning_demo_mode_title">Демо режим включен</string>
<string name="warning_developer_card_message">Отсканированная вами карта является картой разработчика. Не используйте ее для создания своего кошелька.</string>
<string name="warning_developer_card_title">Не для пользователя!</string>
<string name="warning_existential_deposit_message">Cеть %1$s использует концепцию экзистенциального депозита. Если баланс вашего счета будет ниже %2$s, то он будет деактивирован, а средства на счете уничтожены.</string>
<string name="warning_existential_deposit_title">Для работы с сетью необходим депозит</string>
<string name="warning_express_no_exchangeable_coins_description">У вас в списке нет монет доступных для обмена с %s</string>
<string name="warning_express_no_exchangeable_coins_title">Нет доступных токенов для обмена</string>
<string name="warning_express_refresh_required_title">Cервис временно недоступен</string>
<string name="warning_express_too_minimal_amount_description">Пожалуйста, измените сумму для обмена</string>
<string name="warning_express_too_minimal_amount_title">Сумма для обмена должна быть не менее %s</string>
<string name="warning_failed_to_verify_card_message">Возможно, данная карта - образец или подделка</string>
<string name="warning_failed_to_verify_card_title">Ошибка проверки подлинности</string>
<string name="warning_low_signatures_message">На этой карте осталось всего %s подписей. Вам следует вывести все ваши средства.</string>

View file

@ -90,6 +90,7 @@
<string name="common_submit">提交</string>
<string name="common_success">成功</string>
<string name="common_swap">交換</string>
<string name="common_fee_label">費用</string>
<string name="common_terms_and_conditions">條款和條件</string>
<string name="common_transactions">交易</string>
<string name="common_understand">我了解</string>
@ -178,7 +179,7 @@
<string name="onboarding_access_codes_doesnt_match">輸入的訪問密碼與初始訪問密碼不匹配</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">您已添加一張備用卡。備份過程完成後,您將無法添加更多備份卡。如果您還有一張卡,請將其添加到備份中。您想繼續備份過程嗎?</string>
<string name="onboarding_backup_exit_warning">備份過程已部分完成。你現在不能退出</string>
<string name="onboarding_balance_title">餘額</string>
<string name="common_balance_title">餘額</string>
<string name="onboarding_button_add_backup_card">添加備用卡</string>
<string name="onboarding_button_backup_card_format">掃描卡片 #%d</string>
<string name="onboarding_button_backup_now">立即備份</string>
@ -294,7 +295,6 @@
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_fee_include_description">包含費用</string>
<string name="send_fee_label">費用</string>
<string name="send_fee_picker_low"></string>
<string name="send_fee_picker_normal">正常</string>
<string name="send_fee_picker_priority">優先</string>

View file

@ -64,6 +64,7 @@
<string name="common_approval">Approval</string>
<string name="common_attention">Attention</string>
<string name="common_balance">Balance: %s</string>
<string name="common_balance_title">Balance</string>
<string name="common_biometric_authentication">biometric authentication</string>
<string name="common_biometrics">biometrics</string>
<string name="common_buy">Buy</string>
@ -84,8 +85,10 @@
<string name="common_error">Error</string>
<string name="common_exchange">Exchange</string>
<string name="common_explore">Explore</string>
<string name="common_explore_history">Explore history</string>
<string name="common_explore_transaction_history">Explore transaction history</string>
<string name="common_explorer">Explorer</string>
<string name="common_fee_label">Fee</string>
<string name="common_fee_selector_footer">Network transaction fees are used to support network security, incentivize validators, allocate resources, and determine transaction priority.</string>
<string name="common_fee_selector_option_custom">Custom</string>
<string name="common_fee_selector_option_fast">Fast</string>
@ -185,14 +188,46 @@
<string name="disclaimer_title">Terms of Service</string>
<string name="error_update_app">Oops, the current version of the application is not ready to work with this card, please check for updates.</string>
<string name="error_wrong_wallet_tapped">You have used a card from another wallet. Tap the card associated with this wallet</string>
<string name="exchange_receive_view_header">You Receive</string>
<string name="exchange_send_view_header">You Send</string>
<string name="exchange_receive_view_header">You receive</string>
<string name="exchange_send_view_header">You send</string>
<string name="exchange_tokens_available_tokens_header">My tokens</string>
<string name="exchange_tokens_empty_tokens">You don\'t have any added tokens yet. Add tokens via Market to swap</string>
<string name="exchange_tokens_unavailable_tokens_header">Unavailable for swap from %s</string>
<string name="express_choose_providers_subtitle">Providers facilitate transactions, ensuring smooth and efficient token exchanges</string>
<string name="express_choose_providers_title">Choose Provider</string>
<string name="express_provider_best_rate">Best Rate</string>
<string name="exchange_tokens_empty_tokens">You haven\'t added any tokens yet. Add tokens via Market to swap</string>
<string name="exchange_tokens_unavailable_tokens_header">Cannot be swapped for %s</string>
<string name="express_cex_status_button_title">Status</string>
<string name="express_choose_providers_subtitle">Providers facilitate transactions, ensuring smooth and efficient token swaps</string>
<string name="express_choose_providers_title">Choose provider</string>
<string name="express_estimated_amount">Estimated amount</string>
<string name="express_exchange_by">Exchange by %s</string>
<string name="express_exchange_notification_failed_text">Visit providers website to refund your money</string>
<string name="express_exchange_notification_failed_title">Operation failed by provider</string>
<string name="express_exchange_notification_verification_text">Visit providers website for verification</string>
<string name="express_exchange_notification_verification_title">KYC verification required by provider</string>
<string name="express_exchange_status_confirmed">Confirmed</string>
<string name="express_exchange_status_confirming">Confirming</string>
<string name="express_exchange_status_confirming_active">Confirming…</string>
<string name="express_exchange_status_exchanged">Exchanged</string>
<string name="express_exchange_status_exchanging">Exchanging</string>
<string name="express_exchange_status_exchanging_active">Exchanging…</string>
<string name="express_exchange_status_failed">Failed</string>
<string name="express_exchange_status_received">Deposit received</string>
<string name="express_exchange_status_receiving">Awaiting deposit</string>
<string name="express_exchange_status_receiving_active">Awaiting deposit…</string>
<string name="express_exchange_status_refunded">Refunded</string>
<string name="express_exchange_status_sending">Sending to you</string>
<string name="express_exchange_status_sending_active">Sending to you…</string>
<string name="express_exchange_status_sent">Sent</string>
<string name="express_exchange_status_subtitle">Provider-sourced data. Estimated amount subject to change.</string>
<string name="express_exchange_status_title">Exchange status</string>
<string name="express_exchange_status_verified">Verified</string>
<string name="express_exchange_status_verifying">Verification required</string>
<string name="express_exchange_token_list_subtitle">List of all tokens added to your wallet</string>
<string name="express_fetch_best_rates">Fetching best rates...</string>
<string name="express_floating_rate">Floating rate</string>
<string name="express_go_to_provider">Go to provider</string>
<string name="express_provider">Provider</string>
<string name="express_provider_best_rate">Best rate</string>
<string name="express_provider_min_amount">Available from %s</string>
<string name="express_provider_not_available">Unavailable for this pair</string>
<string name="express_provider_permission_needed">Permission Needed</string>
<string name="feedback_data_collection_message">The following information is optional. You can erase it if you don\'t want to share it.</string>
<string name="feedback_preface_rate_negative">Tell us what functions you are missing, and we will try to help you.</string>
@ -267,7 +302,6 @@
<string name="onboarding_access_codes_doesnt_match">Entered access code didn\'t match the initial access code</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">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?</string>
<string name="onboarding_backup_exit_warning">The backup process is partly complete. You can\'t exit it now.</string>
<string name="onboarding_balance_title">Balance</string>
<string name="onboarding_button_add_backup_card">Add a backup card</string>
<string name="onboarding_button_backup_card_format">Scan the card #%d</string>
<string name="onboarding_button_backup_now">Backup now</string>
@ -413,9 +447,7 @@
<string name="send_extras_error_invalid_memo">Invalid Memo. It won\'t be added to the transaction.</string>
<string name="send_extras_hint_destination_tag">Tag</string>
<string name="send_extras_hint_memo">Memo</string>
<string name="send_insufficient_funds">Insufficient funds for transfer</string>
<string name="send_fee_include_description">Include fee</string>
<string name="send_fee_label">Fee</string>
<string name="send_fee_picker_low">Low</string>
<string name="send_fee_picker_normal">Normal</string>
<string name="send_fee_picker_priority">Priority</string>
@ -490,7 +522,7 @@
<string name="story_meet_title">Meet Tangem</string>
<string name="story_web3_description">Exchange, buy NFT\'s, make loans and deposits in more than 100 different decentralized services</string>
<string name="story_web3_title">Web 3.0 Compatible</string>
<string name="swapping_approve_information_text">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.</string>
<string name="swapping_approve_information_text">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.</string>
<string name="swapping_approve_information_title">Approve</string>
<string name="swapping_error_wrapper">Error: %s</string>
<string name="swapping_generic_error">There was an error. Please try again.</string>
@ -498,23 +530,25 @@
<string name="swapping_high_price_impact">High price impact!</string>
<string name="swapping_high_price_impact_description">Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome.</string>
<string name="swapping_insufficient_funds">Insufficient funds</string>
<string name="swapping_not_enough_funds_for_fee">Not enough funds for fee in your %1$s wallet to create a transaction. Top up your %2$s wallet first.</string>
<string name="swapping_not_enough_funds_for_fee">Insufficient funds in your %1$s wallet to cover fees. Top up your %2$s wallet first.</string>
<string name="swapping_pending_transaction_subtitle">Transaction in progress...</string>
<string name="swapping_pending_transaction_title">Waiting</string>
<string name="swapping_permission_buttons_approve">Approve</string>
<string name="swapping_permission_current_transaction">Current transaction</string>
<string name="swapping_permission_fee_footer">The token approval network fee will be charged to confirm that you are the one allowing your token to be used for the exchange.</string>
<string name="swapping_permission_fee_footer">The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap.</string>
<string name="swapping_permission_header">Give Permission</string>
<string name="swapping_permission_policy_type_footer">Specify the approve limit for the selected token</string>
<string name="swapping_permission_rows_amount">Amount %s</string>
<string name="swapping_permission_rows_spender">Spender</string>
<string name="swapping_permission_rows_your_wallet">Your Wallet</string>
<string name="swapping_permission_subheader">To continue you need to allow 1inch smart contracts to use your %s</string>
<string name="swapping_permission_rows_your_wallet">Your wallet</string>
<string name="swapping_permission_subheader">To continue, grant 1inch smart contracts permission to use your %s</string>
<string name="swapping_permission_unlimited">Unlimited</string>
<string name="swapping_success_from_title">You swap</string>
<string name="swapping_success_to_title">You receive</string>
<string name="swapping_success_view_explorer_button_title">View in Explorer</string>
<string name="swapping_success_view_title">In progress</string>
<string name="swapping_swap_action">Swap</string>
<string name="swapping_swap_of_to">Swap of %s to</string>
<string name="swapping_swap_of_to">Swap %s for</string>
<string name="swapping_tangem_fee_disclaimer">Quotes include an additional Tangem commission of %s. This helps us deliver a top-of-the-line product.</string>
<string name="swapping_token_list_other_tokens">Other tokens</string>
<string name="swapping_token_list_title">Choose token</string>
@ -636,12 +670,20 @@
<string name="warning_button_like_it">Like it</string>
<string name="warning_button_ok">Ok, Got it!</string>
<string name="warning_button_really_cool">Really cool!</string>
<string name="warning_button_refresh">Refresh</string>
<string name="warning_demo_mode_message">You are currently in the Demo mode</string>
<string name="warning_demo_mode_title">Demo mode active</string>
<string name="warning_developer_card_message">The card you scanned is a developer card. Do not use it to create your wallet.</string>
<string name="warning_developer_card_title">Not for users!</string>
<string name="warning_existential_deposit_message">%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.</string>
<string name="warning_existential_deposit_title">Network requires Existential Deposit</string>
<string name="warning_express_no_exchangeable_coins_description">You do not have any %s exchangeable coins in your list</string>
<string name="warning_express_no_exchangeable_coins_title">No available tokens to swap</string>
<string name="warning_express_not_enough_fee_for_token_tx_description">To make a transaction you need to deposit some %1$s %2$s</string>
<string name="warning_express_not_enough_fee_for_token_tx_title">Unable to cover %s fee</string>
<string name="warning_express_refresh_required_title">Service temporary unavailable</string>
<string name="warning_express_too_minimal_amount_description">Please change the amount to swap</string>
<string name="warning_express_too_minimal_amount_title">The amount to swap must be at least %s</string>
<string name="warning_failed_to_verify_card_message">This card might be a production sample or counterfeit</string>
<string name="warning_failed_to_verify_card_title">Authenticity check failed</string>
<string name="warning_low_signatures_message">Only %s signatures are left on this card. You must withdraw all of your funds.</string>

View file

@ -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
}
}
private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "")

View file

@ -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 <a href =
* "https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?node-id=1123%3A3863&t=wwR84h5IsMaMsDhq-1"
* >Figma component</a>
*/
@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

View file

@ -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()) {

View file

@ -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
}
}

View file

@ -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<TextLayoutResult?>(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<TextEllipsis> {
override val values: Sequence<TextEllipsis>
get() = sequenceOf(
TextEllipsis.Middle,
TextEllipsis.End,
TextEllipsis.OffsetEnd("TEXT".length),
TextEllipsis.OffsetEnd("TEXT".length, false),
)
}
//endregion

View file

@ -33,7 +33,7 @@ inline fun <reified T : TangemBottomSheetConfigContent> TangemBottomSheet(
sheetState = sheetState,
containerColor = contentColor,
shape = TangemTheme.shapes.bottomSheetLarge,
dragHandle = { TangemBottomSheetDraggableHeader() },
dragHandle = { TangemBottomSheetDraggableHeader(contentColor) },
) {
content(config.content)
}

View file

@ -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

View file

@ -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<InputRowBestRatePreviewData> {
override val values: Sequence<InputRowBestRatePreviewData>
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

View file

@ -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</a>
*/
@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)
}

View file

@ -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",
)
}
}
}

View file

@ -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 = { },
)
}
}

View file

@ -104,6 +104,16 @@ fun combinedReference(refs: WrappedList<TextReference>): 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

View file

@ -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,

View file

@ -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,
),

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M9.135,2.328L1.702,15.165C1.316,15.832 1.797,16.667 2.568,16.667H17.432C18.202,16.667 18.684,15.832 18.298,15.165L10.865,2.328C10.48,1.663 9.52,1.663 9.135,2.328ZM10.833,11.667H9.167V6.667H10.833V11.667ZM10.833,15H9.167V13.333H10.833V15Z"
android:fillColor="#FFB71B"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14.153,11.13C12.544,11.13 11.142,9.695 9.731,9.695C8.625,9.695 7.825,10.153 7.25,10.798V9.076C7.816,8.457 8.652,8 9.847,8C11.456,8 12.867,9.435 14.269,9.435C15.366,9.435 16.175,8.978 16.75,8.332V10.054C16.184,10.673 15.348,11.13 14.153,11.13ZM14.153,16C12.544,16 11.142,14.565 9.731,14.565C8.625,14.565 7.825,15.022 7.25,15.668V13.946C7.816,13.327 8.652,12.87 9.847,12.87C11.456,12.87 12.867,14.305 14.269,14.305C15.366,14.305 16.175,13.856 16.75,13.202V14.924C16.184,15.543 15.348,16 14.153,16Z"
android:fillColor="#909090"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11,13H13V7H11V13Z"
android:fillColor="#FFB71B"/>
<path
android:pathData="M11,17H13V15H11V17Z"
android:fillColor="#FFB71B"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.16,11V13H16.16L10.66,18.5L12.08,19.92L20,12L12.08,4.08L10.66,5.5L16.16,11H4.16Z"
android:fillColor="#1E1E1E"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M6.31,26.499C6.508,25.666 6.773,24.813 7.102,23.949C8.493,26.484 10.368,29 12.684,31.316C15.001,33.632 17.516,35.507 20.051,36.898C19.187,37.227 18.334,37.492 17.501,37.69C13.387,38.669 10.043,37.991 8.026,35.974C6.009,33.957 5.331,30.613 6.31,26.499ZM6.862,37.139C3.507,33.784 3.448,27.963 6.13,22C3.448,16.037 3.507,10.216 6.862,6.862C10.216,3.507 16.037,3.448 22,6.13C27.963,3.448 33.784,3.507 37.139,6.862C40.493,10.216 40.552,16.037 37.87,22C40.553,27.963 40.493,33.784 37.139,37.139C33.784,40.493 27.963,40.552 22,37.87C16.037,40.552 10.216,40.493 6.862,37.139ZM13.849,30.152C11.273,27.576 9.292,24.765 7.948,22C9.292,19.235 11.273,16.424 13.849,13.849C16.424,11.273 19.235,9.292 22,7.948C24.765,9.292 27.576,11.273 30.152,13.849C32.728,16.424 34.708,19.235 36.052,22C34.708,24.765 32.727,27.576 30.152,30.152C27.576,32.727 24.765,34.708 22,36.052C19.235,34.708 16.425,32.727 13.849,30.152ZM12.684,12.684C15,10.368 17.515,8.493 20.051,7.102C19.187,6.773 18.334,6.508 17.502,6.31C13.388,5.331 10.044,6.009 8.026,8.026C6.009,10.043 5.331,13.387 6.31,17.501C6.509,18.334 6.773,19.187 7.102,20.051C8.493,17.515 10.368,15 12.684,12.684ZM31.316,12.684C29,10.368 26.485,8.493 23.949,7.102C24.813,6.773 25.666,6.508 26.499,6.31C30.613,5.331 33.957,6.009 35.974,8.026C37.991,10.043 38.669,13.387 37.69,17.501C37.492,18.334 37.227,19.187 36.898,20.051C35.507,17.515 33.632,15 31.316,12.684ZM31.316,31.316C33.632,29 35.507,26.484 36.898,23.949C37.227,24.813 37.492,25.666 37.69,26.499C38.67,30.613 37.992,33.957 35.974,35.974C33.957,37.991 30.613,38.669 26.499,37.69C25.666,37.492 24.813,37.227 23.949,36.898C26.485,35.507 29,33.632 31.316,31.316ZM22.539,19.035L21.919,14.838L21.299,19.035C21.112,20.303 20.116,21.299 18.848,21.486L14.65,22.106L18.848,22.726C20.116,22.914 21.112,23.91 21.299,25.178L21.919,29.375L22.539,25.178C22.726,23.91 23.722,22.914 24.991,22.726L29.188,22.106L24.991,21.486C23.722,21.299 22.726,20.303 22.539,19.035Z"
android:fillColor="#EBEBEB"
android:fillType="evenOdd"/>
</vector>

View file

@ -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(

View file

@ -1,4 +1,4 @@
package com.tangem.feature.swap.viewmodels
package com.tangem.utils.coroutines
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay

View file

@ -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

View file

@ -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}")
}
}

View file

@ -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
}
}

View file

@ -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<CryptoCurrency.ID>,
appCurrencyId: String,

View file

@ -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),
)
}

View file

@ -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)

View file

@ -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,

View file

@ -0,0 +1,34 @@
package com.tangem.domain.tokens.models.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
class TokenExchangeAnalyticsEvent(
event: String,
params: Map<String, String> = 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"),
)
}

View file

@ -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<TokenActionsState.ActionState> {
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))

View file

@ -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<TokenListError, CryptoCurrencyStatus> {
val operations = CurrenciesStatusesOperations(
userWalletId = userWalletId,
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
)
return operations.getCurrencyStatusSync(cryptoCurrencyId)
.mapLeft { error -> error.mapToTokenListError() }
}
}

View file

@ -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<TokenListError, List<CryptoCurrencyStatus>> {
val operations = CurrenciesStatusesOperations(
userWalletId = userWalletId,
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
)
return operations.getCurrenciesStatusesSync()
.mapLeft { error -> error.mapToTokenListError() }
}
}

View file

@ -67,6 +67,46 @@ internal class CurrenciesStatusesOperations(
}
}
suspend fun getCurrenciesStatusesSync(): Either<Error, List<CryptoCurrencyStatus>> {
return either {
catch(
block = {
val nonEmptyCurrencies =
currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId).toNonEmptyListOrNull()
?: return emptyList<CryptoCurrencyStatus>().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<Error, CryptoCurrencyStatus> {
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<Either<Error, List<CryptoCurrencyStatus>>> {
return flow {
val nonEmptyCurrencies = recover(

View file

@ -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
}

View file

@ -29,4 +29,6 @@ interface QuotesRepository {
* @return A [Flow] emitting a set of quotes corresponding to the specified cryptocurrencies.
*/
suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote>
suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote
}

View file

@ -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,
)

View file

@ -20,4 +20,9 @@ internal class MockQuotesRepository(
override suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote> {
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 }
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}
}

View file

@ -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),
)
}

View file

@ -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)
}

View file

@ -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<SavedSwapTransactionListModel>? = 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<List<SavedSwapTransactionListModel>?> {
return appPreferencesStore.getObjectList<SavedSwapTransactionListModel>(
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<SavedSwapTransactionListModel>? = 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<SavedLastSwappedCryptoCurrency>(
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<SavedLastSwappedCryptoCurrency>? = 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<SavedSwapTransactionListModel>.updateList(
userWalletId: UserWalletId,
fromCryptoCurrencyId: CryptoCurrency.ID,
toCryptoCurrencyId: CryptoCurrency.ID,
transactions: List<SavedSwapTransactionModel>,
): List<SavedSwapTransactionListModel> {
return addOrReplace(
item = SavedSwapTransactionListModel(
userWalletId = userWalletId.stringValue,
fromCryptoCurrencyId = fromCryptoCurrencyId.value,
toCryptoCurrencyId = toCryptoCurrencyId.value,
transactions = transactions,
),
predicate = {
it.checkId(
checkUserWalletId = userWalletId,
fromCurrencyId = fromCryptoCurrencyId,
toCurrencyId = toCryptoCurrencyId,
)
},
)
}
}

View file

@ -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<CryptoCurrency>,
): List<SwapPairLeast> {
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<NetworkLeastTokenInfo>,
to: List<NetworkLeastTokenInfo>,
): List<SwapPair> {
return tangemExpressApi.getPairs(
PairsRequestBody(
from = from,
to = to,
),
).getOrThrow()
}
override suspend fun getExchangeStatus(txId: String): Either<UnknownError, ExchangeStatusModel> {
return withContext(coroutineDispatcher.io) {
either {
catch(
{
exchangeStatusConverter.convert(
tangemExpressApi
.getExchangeStatus(txId)
.getOrThrow(),
)
},
{
raise(UnknownError(it.message))
},
)
}
}
}
override suspend fun getRates(currencyId: String, tokenIds: List<String>): Map<String, Double> {
// 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<QuoteModel> {
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<SwapDataModel> {
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"

View file

@ -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<ExpressErrorResponse>,
) : Converter<String, DataError> {
@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
}
}
}

View file

@ -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<ExchangeStatusResponse, ExchangeStatusModel> {
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,
)
}
}

View file

@ -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<ExchangeDataResponse, SwapDataModel> {
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),
)
}
}
}

View file

@ -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<CryptoCurrency, LeastTokenInfo> {
override fun convert(value: CryptoCurrency): LeastTokenInfo {
return LeastTokenInfo(
contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0",
network = Blockchain.fromId(value.id.rawNetworkId).toNetworkId(),
)
}
}

View file

@ -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<QuoteResponse, QuoteModel> {
override fun convert(value: QuoteResponse): QuoteModel {
return QuoteModel(
toTokenAmount = createFromAmountWithOffset(value.toTokenAmount, value.toToken.decimals),
)
}
}

View file

@ -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<RateType, RateTypeDomain> {
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
}
}
}

View file

@ -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<SwapResponse, SwapDataModel> {
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,
)
}
}

View file

@ -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<SwapPairsWithProviders, List<SwapPairDomain>> {
private val rateTypeConverter = RateTypeConverter()
override fun convert(value: SwapPairsWithProviders): List<SwapPairDomain> {
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<String, ExchangeProvider>,
): 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
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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
*

View file

@ -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)
}
}
}

View file

@ -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
}
}

View file

@ -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?
}

View file

@ -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<SwapProvider>,
amountToSwap: String,
selectedFee: FeeType = FeeType.NORMAL,
): SwapState
): Map<SwapProvider, SwapState>
/**
* 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
}

View file

@ -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<CryptoCurrency>): List<SwapPairLeast>
suspend fun getRates(currencyId: String, tokenIds: List<String>): Map<String, Double>
suspend fun getExchangeableTokens(networkId: String): List<Currency>
suspend fun getExchangeStatus(txId: String): Either<UnknownError, ExchangeStatusModel>
@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<QuoteModel>
/**
@ -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<SwapDataModel>
/**
* 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<SwapDataModel>
fun getNativeTokenForNetwork(networkId: String): CryptoCurrency
}

View file

@ -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<List<SavedSwapTransactionListModel>?>
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?
}

View file

@ -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<Currency>)
fun cacheInWalletTokens(tokens: List<TokenWithBalance>)
fun cacheLoadedTokens(tokens: List<TokenWithBalance>)
fun cacheBalances(networkId: String, derivationPath: String?, balances: Map<String, SwapAmount>)
fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String)
fun getAvailableTokens(networkId: String): List<Currency>
fun getInWalletTokens(): List<TokenWithBalance>
fun getLoadedTokens(): List<TokenWithBalance>
fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount?
fun getLastFeeForNetwork(networkId: String): BigDecimal?
}

View file

@ -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<String, List<Currency>> = mutableMapOf()
private val feesForNetworks: MutableMap<String, BigDecimal> = mutableMapOf()
private val tokensBalances: MutableMap<String, Map<String, SwapAmount>> = mutableMapOf()
private val lastInWalletTokens = mutableListOf<TokenWithBalance>()
private val lastLoadedTokens = mutableListOf<TokenWithBalance>()
override fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String) {
feesForNetworks[networkId] = fee
}
override fun cacheInWalletTokens(tokens: List<TokenWithBalance>) {
lastInWalletTokens.clear()
lastInWalletTokens.addAll(tokens)
}
override fun cacheLoadedTokens(tokens: List<TokenWithBalance>) {
lastLoadedTokens.clear()
lastLoadedTokens.addAll(tokens)
}
override fun getInWalletTokens(): List<TokenWithBalance> {
return lastInWalletTokens
}
override fun getLoadedTokens(): List<TokenWithBalance> {
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<String, SwapAmount>) {
tokensBalances[createKeyFrom(networkId, derivationPath)] = balances
}
override fun cacheAvailableToSwapTokens(networkId: String, tokens: List<Currency>) {
availableTokensForNetwork[networkId] = tokens
}
override fun getLastFeeForNetwork(networkId: String): BigDecimal? {
return feesForNetworks[networkId]
}
override fun getAvailableTokens(networkId: String): List<Currency> {
return availableTokensForNetwork.getOrElse(networkId) { emptyList() }
}
private fun createKeyFrom(networkId: String, derivationPath: String?): String {
return "$networkId;$derivationPath"
}
}

Some files were not shown because too many files have changed in this diff Show more