Updated on 2026-08-14
This commit is contained in:
commit
7047ce9377
270 changed files with 5769 additions and 1976 deletions
|
|
@ -114,6 +114,8 @@ dependencies {
|
|||
implementation(projects.domain.notifications)
|
||||
implementation(projects.domain.notifications.models)
|
||||
implementation(projects.domain.notifications.toggles)
|
||||
implementation(projects.domain.swap.models)
|
||||
implementation(projects.domain.swap)
|
||||
|
||||
implementation(projects.common)
|
||||
implementation(projects.common.routing)
|
||||
|
|
@ -160,6 +162,7 @@ dependencies {
|
|||
implementation(projects.data.quotes)
|
||||
implementation(projects.data.blockaid)
|
||||
implementation(projects.data.notifications)
|
||||
implementation(projects.data.swap)
|
||||
|
||||
/** Features */
|
||||
implementation(projects.features.referral.impl)
|
||||
|
|
@ -170,6 +173,8 @@ dependencies {
|
|||
implementation(projects.features.swap.domain)
|
||||
implementation(projects.features.swap.domain.api)
|
||||
implementation(projects.features.swap.data)
|
||||
implementation(projects.features.swapV2.api)
|
||||
implementation(projects.features.swapV2.impl)
|
||||
implementation(projects.features.tester.api)
|
||||
implementation(projects.features.tester.impl)
|
||||
implementation(projects.features.wallet.api)
|
||||
|
|
@ -210,8 +215,8 @@ dependencies {
|
|||
implementation(projects.features.walletconnect.impl)
|
||||
implementation(projects.features.usedesk.api)
|
||||
implementation(projects.features.usedesk.impl)
|
||||
implementation(projects.features.feeSelector.api)
|
||||
implementation(projects.features.feeSelector.impl)
|
||||
implementation(projects.features.hotWallet.api)
|
||||
implementation(projects.features.hotWallet.impl)
|
||||
|
||||
/** AndroidX libraries */
|
||||
implementation(deps.androidx.core.ktx)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,9 @@ package com.tangem.tap.data
|
|||
import android.content.Context
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.tangem.common.services.secure.SecureStorage
|
||||
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
|
||||
import com.tangem.domain.visa.model.VisaAuthTokens
|
||||
import com.tangem.sdk.storage.AndroidSecureStorage
|
||||
import com.tangem.sdk.storage.createEncryptedSharedPreferences
|
||||
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -21,11 +19,9 @@ internal class DefaultVisaAuthTokenStorage @Inject constructor(
|
|||
) : VisaAuthTokenStorage {
|
||||
|
||||
private val secureStorage by lazy {
|
||||
AndroidSecureStorage(
|
||||
preferences = SecureStorage.createEncryptedSharedPreferences(
|
||||
context = applicationContext,
|
||||
storageName = "visa_auth_storage",
|
||||
),
|
||||
AndroidSecureStorageV2(
|
||||
appContext = applicationContext,
|
||||
name = "visa_auth_storage",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,9 @@ package com.tangem.tap.data
|
|||
import android.content.Context
|
||||
import com.tangem.common.extensions.toByteArray
|
||||
import com.tangem.common.extensions.toInt
|
||||
import com.tangem.common.services.secure.SecureStorage
|
||||
import com.tangem.datasource.local.visa.VisaOTPStorage
|
||||
import com.tangem.datasource.local.visa.VisaOtpData
|
||||
import com.tangem.sdk.storage.AndroidSecureStorage
|
||||
import com.tangem.sdk.storage.createEncryptedSharedPreferences
|
||||
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -21,11 +19,9 @@ class DefaultVisaOTPStorage @Inject constructor(
|
|||
) : VisaOTPStorage {
|
||||
|
||||
private val secureStorage by lazy {
|
||||
AndroidSecureStorage(
|
||||
preferences = SecureStorage.createEncryptedSharedPreferences(
|
||||
context = applicationContext,
|
||||
storageName = "visa_otp_storage",
|
||||
),
|
||||
AndroidSecureStorageV2(
|
||||
appContext = applicationContext,
|
||||
name = "visa_otp_storage",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import com.tangem.domain.managetokens.*
|
|||
import com.tangem.domain.managetokens.repository.CustomTokensRepository
|
||||
import com.tangem.domain.managetokens.repository.ManageTokensRepository
|
||||
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
|
||||
import com.tangem.domain.staking.repositories.StakingRepository
|
||||
import com.tangem.domain.tokens.TokensFeatureToggles
|
||||
|
|
@ -71,7 +71,7 @@ internal object ManageTokensDomainModule {
|
|||
derivationsRepository: DerivationsRepository,
|
||||
stakingRepository: StakingRepository,
|
||||
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
|
||||
tokensFeatureToggles: TokensFeatureToggles,
|
||||
): SaveManagedTokensUseCase {
|
||||
|
|
@ -82,7 +82,7 @@ internal object ManageTokensDomainModule {
|
|||
derivationsRepository = derivationsRepository,
|
||||
stakingRepository = stakingRepository,
|
||||
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
|
||||
tokensFeatureToggles = tokensFeatureToggles,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import com.tangem.domain.markets.*
|
|||
import com.tangem.domain.markets.repositories.MarketsTokenRepository
|
||||
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteSupplier
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
import com.tangem.domain.settings.repositories.SettingsRepository
|
||||
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
|
||||
import com.tangem.domain.staking.repositories.StakingRepository
|
||||
|
|
@ -52,8 +52,8 @@ object MarketsDomainModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetTokenQuotesUseCase(singleQuoteSupplier: SingleQuoteSupplier): GetCurrencyQuotesUseCase {
|
||||
return GetCurrencyQuotesUseCase(singleQuoteSupplier = singleQuoteSupplier)
|
||||
fun provideGetTokenQuotesUseCase(singleQuoteStatusSupplier: SingleQuoteStatusSupplier): GetCurrencyQuotesUseCase {
|
||||
return GetCurrencyQuotesUseCase(singleQuoteStatusSupplier = singleQuoteStatusSupplier)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
@ -64,7 +64,7 @@ object MarketsDomainModule {
|
|||
currenciesRepository: CurrenciesRepository,
|
||||
stakingRepository: StakingRepository,
|
||||
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
|
||||
tokensFeatureToggles: TokensFeatureToggles,
|
||||
): SaveMarketTokensUseCase {
|
||||
|
|
@ -74,7 +74,7 @@ object MarketsDomainModule {
|
|||
currenciesRepository = currenciesRepository,
|
||||
stakingRepository = stakingRepository,
|
||||
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
|
||||
tokensFeatureToggles = tokensFeatureToggles,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package com.tangem.tap.di.domain
|
|||
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
|
||||
import com.tangem.domain.nft.*
|
||||
import com.tangem.domain.nft.repository.NFTRepository
|
||||
import com.tangem.domain.quotes.single.SingleQuoteFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteSupplier
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.wallets.repository.WalletsRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -92,18 +92,18 @@ internal object NFTDomainModule {
|
|||
@Singleton
|
||||
fun provideGetNFTPriceUseCase(
|
||||
nftRepository: NFTRepository,
|
||||
singleQuoteSupplier: SingleQuoteSupplier,
|
||||
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
|
||||
): GetNFTPriceUseCase {
|
||||
return GetNFTPriceUseCase(nftRepository, singleQuoteSupplier)
|
||||
return GetNFTPriceUseCase(nftRepository, singleQuoteStatusSupplier)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFetchNFTPriceUseCase(
|
||||
nftRepository: NFTRepository,
|
||||
singleQuoteFetcher: SingleQuoteFetcher,
|
||||
singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
|
||||
): FetchNFTPriceUseCase {
|
||||
return FetchNFTPriceUseCase(nftRepository, singleQuoteFetcher)
|
||||
return FetchNFTPriceUseCase(nftRepository, singleQuoteStatusFetcher)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
package com.tangem.tap.di.domain
|
||||
|
||||
import com.tangem.domain.swap.SwapErrorResolver
|
||||
import com.tangem.domain.swap.SwapRepositoryV2
|
||||
import com.tangem.domain.swap.usecase.GetSwapSupportedPairsUseCase
|
||||
import com.tangem.feature.swap.domain.GetAvailablePairsUseCase
|
||||
import com.tangem.feature.swap.domain.api.SwapRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import com.tangem.feature.swap.domain.api.SwapRepository as OldSwapRepository
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
|
|
@ -17,7 +20,19 @@ internal object SwapDomainModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetAvailablePairsUseCase(swapRepository: SwapRepository): GetAvailablePairsUseCase {
|
||||
fun provideGetAvailablePairsUseCase(swapRepository: OldSwapRepository): GetAvailablePairsUseCase {
|
||||
return GetAvailablePairsUseCase(swapRepository = swapRepository)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetSwapSupportedPairsUseCase(
|
||||
swapRepositoryV2: SwapRepositoryV2,
|
||||
swapErrorResolver: SwapErrorResolver,
|
||||
): GetSwapSupportedPairsUseCase {
|
||||
return GetSwapSupportedPairsUseCase(
|
||||
swapRepositoryV2 = swapRepositoryV2,
|
||||
swapErrorResolver = swapErrorResolver,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@ import com.tangem.domain.networks.repository.NetworksRepository
|
|||
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
|
||||
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
|
||||
import com.tangem.domain.promo.PromoRepository
|
||||
import com.tangem.domain.quotes.QuotesRepositoryV2
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteSupplier
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
|
||||
import com.tangem.domain.staking.repositories.StakingRepository
|
||||
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
|
||||
|
|
@ -42,7 +42,7 @@ internal object TokensDomainModule {
|
|||
currenciesRepository: CurrenciesRepository,
|
||||
stakingRepository: StakingRepository,
|
||||
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
singleYieldBalanceFetcher: SingleYieldBalanceFetcher,
|
||||
tokensFeatureToggles: TokensFeatureToggles,
|
||||
): AddCryptoCurrenciesUseCase {
|
||||
|
|
@ -50,7 +50,7 @@ internal object TokensDomainModule {
|
|||
currenciesRepository = currenciesRepository,
|
||||
stakingRepository = stakingRepository,
|
||||
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
singleYieldBalanceFetcher = singleYieldBalanceFetcher,
|
||||
tokensFeatureToggles = tokensFeatureToggles,
|
||||
)
|
||||
|
|
@ -62,7 +62,7 @@ internal object TokensDomainModule {
|
|||
currenciesRepository: CurrenciesRepository,
|
||||
stakingRepository: StakingRepository,
|
||||
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
|
||||
tokensFeatureToggles: TokensFeatureToggles,
|
||||
): FetchTokenListUseCase {
|
||||
|
|
@ -70,7 +70,7 @@ internal object TokensDomainModule {
|
|||
currenciesRepository = currenciesRepository,
|
||||
stakingRepository = stakingRepository,
|
||||
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
|
||||
tokensFeatureToggles = tokensFeatureToggles,
|
||||
)
|
||||
|
|
@ -161,7 +161,7 @@ internal object TokensDomainModule {
|
|||
currenciesRepository: CurrenciesRepository,
|
||||
stakingRepository: StakingRepository,
|
||||
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
singleYieldBalanceFetcher: SingleYieldBalanceFetcher,
|
||||
tokensFeatureToggles: TokensFeatureToggles,
|
||||
): FetchCurrencyStatusUseCase {
|
||||
|
|
@ -169,7 +169,7 @@ internal object TokensDomainModule {
|
|||
currenciesRepository = currenciesRepository,
|
||||
stakingRepository = stakingRepository,
|
||||
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
singleYieldBalanceFetcher = singleYieldBalanceFetcher,
|
||||
tokensFeatureToggles = tokensFeatureToggles,
|
||||
)
|
||||
|
|
@ -181,7 +181,7 @@ internal object TokensDomainModule {
|
|||
currenciesRepository: CurrenciesRepository,
|
||||
stakingRepository: StakingRepository,
|
||||
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
|
||||
tokensFeatureToggles: TokensFeatureToggles,
|
||||
): FetchCardTokenListUseCase {
|
||||
|
|
@ -189,7 +189,7 @@ internal object TokensDomainModule {
|
|||
currenciesRepository = currenciesRepository,
|
||||
stakingRepository = stakingRepository,
|
||||
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
|
||||
tokensFeatureToggles = tokensFeatureToggles,
|
||||
)
|
||||
|
|
@ -346,11 +346,11 @@ internal object TokensDomainModule {
|
|||
@Singleton
|
||||
fun provideRefreshMultiCurrencyWalletQuotesUseCase(
|
||||
currenciesRepository: CurrenciesRepository,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
): RefreshMultiCurrencyWalletQuotesUseCase {
|
||||
return RefreshMultiCurrencyWalletQuotesUseCase(
|
||||
currenciesRepository = currenciesRepository,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -368,27 +368,27 @@ internal object TokensDomainModule {
|
|||
fun provideBaseCurrenciesStatusesOperations(
|
||||
tokensFeatureToggles: TokensFeatureToggles,
|
||||
currenciesRepository: CurrenciesRepository,
|
||||
quotesRepositoryV2: QuotesRepositoryV2,
|
||||
quotesRepository: QuotesRepository,
|
||||
stakingRepository: StakingRepository,
|
||||
singleNetworkStatusSupplier: SingleNetworkStatusSupplier,
|
||||
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
|
||||
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
|
||||
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
singleQuoteSupplier: SingleQuoteSupplier,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
|
||||
singleYieldBalanceSupplier: SingleYieldBalanceSupplier,
|
||||
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
|
||||
): BaseCurrenciesStatusesOperations {
|
||||
return CachedCurrenciesStatusesOperations(
|
||||
currenciesRepository = currenciesRepository,
|
||||
quotesRepositoryV2 = quotesRepositoryV2,
|
||||
quotesRepository = quotesRepository,
|
||||
stakingRepository = stakingRepository,
|
||||
singleNetworkStatusSupplier = singleNetworkStatusSupplier,
|
||||
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
|
||||
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
|
||||
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
singleQuoteSupplier = singleQuoteSupplier,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
|
||||
singleYieldBalanceSupplier = singleYieldBalanceSupplier,
|
||||
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
|
||||
tokensFeatureToggles = tokensFeatureToggles,
|
||||
|
|
@ -400,27 +400,27 @@ internal object TokensDomainModule {
|
|||
fun provideBaseCurrencyStatusOperations(
|
||||
tokensFeatureToggles: TokensFeatureToggles,
|
||||
currenciesRepository: CurrenciesRepository,
|
||||
quotesRepositoryV2: QuotesRepositoryV2,
|
||||
quotesRepository: QuotesRepository,
|
||||
stakingRepository: StakingRepository,
|
||||
singleNetworkStatusSupplier: SingleNetworkStatusSupplier,
|
||||
multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
|
||||
multiNetworkStatusFetcher: MultiNetworkStatusFetcher,
|
||||
singleNetworkStatusFetcher: SingleNetworkStatusFetcher,
|
||||
multiQuoteFetcher: MultiQuoteFetcher,
|
||||
singleQuoteSupplier: SingleQuoteSupplier,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
|
||||
singleYieldBalanceSupplier: SingleYieldBalanceSupplier,
|
||||
multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
|
||||
): BaseCurrencyStatusOperations {
|
||||
return CachedCurrenciesStatusesOperations(
|
||||
currenciesRepository = currenciesRepository,
|
||||
quotesRepositoryV2 = quotesRepositoryV2,
|
||||
quotesRepository = quotesRepository,
|
||||
stakingRepository = stakingRepository,
|
||||
singleNetworkStatusSupplier = singleNetworkStatusSupplier,
|
||||
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
|
||||
multiNetworkStatusFetcher = multiNetworkStatusFetcher,
|
||||
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
singleQuoteSupplier = singleQuoteSupplier,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
|
||||
singleYieldBalanceSupplier = singleYieldBalanceSupplier,
|
||||
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
|
||||
tokensFeatureToggles = tokensFeatureToggles,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.tangem.domain.visa.model.VisaActivationRemoteState
|
|||
import com.tangem.domain.visa.model.VisaCardActivationStatus
|
||||
import com.tangem.domain.wallets.legacy.UserWalletsListManager
|
||||
import com.tangem.sdk.storage.AndroidSecureStorage
|
||||
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||
import com.tangem.sdk.storage.createEncryptedSharedPreferences
|
||||
import com.tangem.tap.domain.userWalletList.implementation.BiometricUserWalletsListManager
|
||||
import com.tangem.tap.domain.userWalletList.implementation.GeneralUserWalletsListManager
|
||||
|
|
@ -80,6 +81,10 @@ internal object UserWalletsListManagerModule {
|
|||
context = applicationContext,
|
||||
storageName = "user_wallets_storage",
|
||||
),
|
||||
androidSecureStorageV2 = AndroidSecureStorageV2(
|
||||
appContext = applicationContext,
|
||||
name = "user_wallets_storage2",
|
||||
),
|
||||
)
|
||||
|
||||
val authenticatedStorage = AuthenticatedStorage(
|
||||
|
|
|
|||
|
|
@ -83,11 +83,14 @@ internal class VisaCardScanHandler @Inject constructor(
|
|||
|
||||
Timber.i("Requesting challenge for wallet authorization")
|
||||
|
||||
val challengeResponse = visaAuthRepository.getCardWalletAuthChallenge(cardWalletAddress = walletAddress.value)
|
||||
.getOrElse {
|
||||
Timber.i("Failed to get Access token for Wallet public key authorization")
|
||||
return CompletionResult.Failure(it.tangemError)
|
||||
}
|
||||
val challengeResponse = visaAuthRepository.getCardWalletAuthChallenge(
|
||||
cardId = card.cardId,
|
||||
// This is the wallet public key, not the address and it's alright, as the API expects it in this format
|
||||
cardWalletAddress = wallet.publicKey.toHexString(),
|
||||
).getOrElse {
|
||||
Timber.i("Failed to get Access token for Wallet public key authorization")
|
||||
return CompletionResult.Failure(it.tangemError)
|
||||
}
|
||||
|
||||
val signChallengeResult = signChallengeWithWallet(
|
||||
publicKey = wallet.publicKey,
|
||||
|
|
@ -96,15 +99,16 @@ internal class VisaCardScanHandler @Inject constructor(
|
|||
|
||||
return when (signChallengeResult) {
|
||||
is CompletionResult.Success -> {
|
||||
val signature = signChallengeResult.data.cardSignature ?: run {
|
||||
Timber.i("Failed to sign challenge with Wallet public key")
|
||||
return CompletionResult.Failure(VisaCardScanError.FailedToSignChallenge.tangemError)
|
||||
}
|
||||
val signature = signChallengeResult.data.walletSignature
|
||||
val salt = signChallengeResult.data.salt
|
||||
|
||||
Timber.i("Challenge signed with Wallet public key")
|
||||
handleWalletAuthorizationTokens(
|
||||
cardWalletAddress = walletAddress.value,
|
||||
signedChallenge = challengeResponse.toSignedChallenge(signature.toHexString()),
|
||||
signedChallenge = challengeResponse.toSignedChallenge(
|
||||
signedChallenge = signature.toHexString(),
|
||||
salt = salt.toHexString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
is CompletionResult.Failure -> {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.tangem.core.ui.utils.findActivity
|
|||
import com.tangem.tap.common.redux.AppState
|
||||
import com.tangem.tap.features.home.api.HomeComponent
|
||||
import com.tangem.tap.features.home.compose.StoriesScreen
|
||||
import com.tangem.tap.features.home.compose.StoriesScreenV2
|
||||
import com.tangem.tap.features.home.redux.HomeAction
|
||||
import com.tangem.tap.features.home.redux.HomeState
|
||||
import com.tangem.tap.store
|
||||
|
|
@ -57,12 +58,22 @@ internal class DefaultHomeComponent @AssistedInject constructor(
|
|||
val activity = LocalContext.current.findActivity()
|
||||
BackHandler(onBack = activity::finish)
|
||||
SystemBarsIconsDisposable(darkIcons = false)
|
||||
StoriesScreen(
|
||||
homeState = homeState,
|
||||
onScanButtonClick = model::onScanClick,
|
||||
onShopButtonClick = model::onShopClick,
|
||||
onSearchTokensClick = model::onSearchClick,
|
||||
)
|
||||
if (homeState.value.isV2StoriesEnabled) {
|
||||
StoriesScreenV2(
|
||||
homeState = homeState,
|
||||
onCreateNewWalletButtonClick = model::onCreateNewWalletScreen,
|
||||
onAddExistingWalletButtonClick = model::onAddExistingWalletScreen,
|
||||
onScanButtonClick = model::onScanClick,
|
||||
)
|
||||
} else {
|
||||
StoriesScreen(
|
||||
homeState = homeState,
|
||||
onScanButtonClick = model::onScanClick,
|
||||
onShopButtonClick = model::onShopClick,
|
||||
onSearchTokensClick = model::onSearchClick,
|
||||
)
|
||||
}
|
||||
|
||||
ChangeRootBackgroundColorEffect(Color(color = 0xFF010101))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ internal class HomeModel @Inject constructor(
|
|||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
fun onCreateNewWalletScreen() {
|
||||
// TODO implement navigation to create new wallet
|
||||
}
|
||||
|
||||
fun onAddExistingWalletScreen() {
|
||||
// TODO implement navigation to add existing wallet
|
||||
}
|
||||
|
||||
fun onScanClick() {
|
||||
analyticsEventHandler.send(IntroductionProcess.ButtonScanCard())
|
||||
scanCard()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,252 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package com.tangem.tap.features.home.compose
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.test.TestTags
|
||||
import com.tangem.tap.features.home.compose.content.*
|
||||
import com.tangem.tap.features.home.compose.views.HomeButtonsV2
|
||||
import com.tangem.tap.features.home.compose.views.StoriesProgressBar
|
||||
import com.tangem.tap.features.home.redux.HomeState
|
||||
import com.tangem.tap.features.home.redux.Stories
|
||||
import com.tangem.wallet.R
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
internal fun StoriesScreenV2(
|
||||
homeState: MutableState<HomeState>,
|
||||
onCreateNewWalletButtonClick: () -> Unit,
|
||||
onAddExistingWalletButtonClick: () -> Unit,
|
||||
onScanButtonClick: () -> Unit,
|
||||
) {
|
||||
val state = homeState.value
|
||||
|
||||
var currentStory by remember { mutableStateOf(state.firstStory) }
|
||||
val currentStoryIndex by rememberUpdatedState(newValue = state.stepOf(currentStory))
|
||||
|
||||
val goToPreviousStory = remember(currentStory, currentStoryIndex) {
|
||||
{ currentStory = state.stories[max(0, currentStoryIndex - 1)] }
|
||||
}
|
||||
val goToNextStory = remember(currentStory, currentStoryIndex) {
|
||||
{
|
||||
currentStory = if (currentStoryIndex < state.stories.lastIndex) {
|
||||
state.stories[currentStoryIndex + 1]
|
||||
} else {
|
||||
state.firstStory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// todo refactor [REDACTED_TASK_KEY]
|
||||
StoriesScreenContentV2(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TestTags.STORIES_SCREEN),
|
||||
config = StoriesScreenContentV2Config(
|
||||
storiesSize = state.stories.lastIndex,
|
||||
currentStoryIndex = currentStoryIndex,
|
||||
currentStory = currentStory,
|
||||
isScanInProgress = homeState.value.scanInProgress,
|
||||
onGoToPreviousStory = goToPreviousStory,
|
||||
onGoToNextStory = goToNextStory,
|
||||
onCreateNewWalletButtonClick = onCreateNewWalletButtonClick,
|
||||
onAddExistingWalletButtonClick = onAddExistingWalletButtonClick,
|
||||
onScanButtonClick = onScanButtonClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Deprecated("Use StoriesContainer from core/ui")
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun StoriesScreenContentV2(config: StoriesScreenContentV2Config, modifier: Modifier = Modifier) {
|
||||
var isPressed by remember { mutableStateOf(value = false) }
|
||||
|
||||
val isPaused = isPressed || config.isScanInProgress
|
||||
val currentStoryDuration = config.currentStory.duration
|
||||
|
||||
Box(
|
||||
modifier = modifier.background(Color(0xFF010101)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
val pressStartTime = System.currentTimeMillis()
|
||||
isPressed = true
|
||||
this.tryAwaitRelease()
|
||||
val pressEndTime = System.currentTimeMillis()
|
||||
val totalPressTime = pressEndTime - pressStartTime
|
||||
if (totalPressTime < 200) config.onGoToPreviousStory()
|
||||
isPressed = false
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
val pressStartTime = System.currentTimeMillis()
|
||||
isPressed = true
|
||||
this.tryAwaitRelease()
|
||||
val pressEndTime = System.currentTimeMillis()
|
||||
val totalPressTime = pressEndTime - pressStartTime
|
||||
if (totalPressTime < 200) config.onGoToNextStory()
|
||||
isPressed = false
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
StoriesProgressBar(
|
||||
steps = config.storiesSize,
|
||||
currentStep = config.currentStoryIndex,
|
||||
stepDuration = currentStoryDuration,
|
||||
paused = isPaused,
|
||||
onStepFinish = config.onGoToNextStory,
|
||||
)
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_tangem_logo),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillHeight,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = TangemTheme.dimens.spacing16,
|
||||
top = TangemTheme.dimens.spacing16,
|
||||
)
|
||||
.height(TangemTheme.dimens.size18)
|
||||
.align(Alignment.Start),
|
||||
)
|
||||
when (config.currentStory) {
|
||||
Stories.TangemIntro -> FirstStoriesContent(
|
||||
isPaused = isPaused,
|
||||
duration = currentStoryDuration,
|
||||
)
|
||||
Stories.RevolutionaryWallet -> StoriesRevolutionaryWallet()
|
||||
Stories.UltraSecureBackup -> StoriesUltraSecureBackup(
|
||||
isPaused = isPaused,
|
||||
stepDuration = currentStoryDuration,
|
||||
)
|
||||
Stories.Currencies -> StoriesCurrencies(isPaused, currentStoryDuration)
|
||||
Stories.Web3 -> StoriesWeb3(isPaused, currentStoryDuration)
|
||||
Stories.WalletForEveryone -> StoriesWalletForEveryone(currentStoryDuration)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = TangemTheme.dimens.spacing16)
|
||||
.padding(horizontal = TangemTheme.dimens.spacing16)
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
|
||||
) {
|
||||
HomeButtonsV2(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
btnScanStateInProgress = config.isScanInProgress,
|
||||
onScanButtonClick = config.onScanButtonClick,
|
||||
onCreateNewWalletButtonClick = config.onCreateNewWalletButtonClick,
|
||||
onAddExistingWalletButtonClick = config.onAddExistingWalletButtonClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class StoriesScreenContentV2Config(
|
||||
val storiesSize: Int,
|
||||
val currentStoryIndex: Int,
|
||||
val currentStory: Stories,
|
||||
val isScanInProgress: Boolean,
|
||||
val onGoToPreviousStory: () -> Unit = {},
|
||||
val onGoToNextStory: () -> Unit = {},
|
||||
val onCreateNewWalletButtonClick: () -> Unit = {},
|
||||
val onAddExistingWalletButtonClick: () -> Unit = {},
|
||||
val onScanButtonClick: () -> Unit = {},
|
||||
)
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun StoriesScreenContentV2Preview(
|
||||
@PreviewParameter(StoriesScreenContentV2ConfigProvider::class) config: StoriesScreenContentV2Config,
|
||||
) {
|
||||
TangemThemePreview {
|
||||
StoriesScreenContentV2(config = config)
|
||||
}
|
||||
}
|
||||
|
||||
private class StoriesScreenContentV2ConfigProvider : CollectionPreviewParameterProvider<StoriesScreenContentV2Config>(
|
||||
collection = listOf(
|
||||
StoriesScreenContentV2Config(
|
||||
storiesSize = 6,
|
||||
currentStoryIndex = 0,
|
||||
currentStory = Stories.TangemIntro,
|
||||
isScanInProgress = true,
|
||||
),
|
||||
StoriesScreenContentV2Config(
|
||||
storiesSize = 6,
|
||||
currentStoryIndex = 1,
|
||||
currentStory = Stories.RevolutionaryWallet,
|
||||
isScanInProgress = false,
|
||||
),
|
||||
StoriesScreenContentV2Config(
|
||||
storiesSize = 6,
|
||||
currentStoryIndex = 2,
|
||||
currentStory = Stories.UltraSecureBackup,
|
||||
isScanInProgress = false,
|
||||
),
|
||||
StoriesScreenContentV2Config(
|
||||
storiesSize = 6,
|
||||
currentStoryIndex = 3,
|
||||
currentStory = Stories.Currencies,
|
||||
isScanInProgress = false,
|
||||
),
|
||||
StoriesScreenContentV2Config(
|
||||
storiesSize = 6,
|
||||
currentStoryIndex = 4,
|
||||
currentStory = Stories.Web3,
|
||||
isScanInProgress = false,
|
||||
),
|
||||
StoriesScreenContentV2Config(
|
||||
storiesSize = 6,
|
||||
currentStoryIndex = 5,
|
||||
currentStory = Stories.WalletForEveryone,
|
||||
isScanInProgress = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
// endregion Preview
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.tangem.tap.features.home.compose.views
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.test.TestTags
|
||||
import com.tangem.wallet.R
|
||||
|
||||
@Composable
|
||||
internal fun HomeButtonsV2(
|
||||
btnScanStateInProgress: Boolean,
|
||||
onScanButtonClick: () -> Unit,
|
||||
onCreateNewWalletButtonClick: () -> Unit,
|
||||
onAddExistingWalletButtonClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CreateNewWalletButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.STORIES_SCREEN_CREATE_NEW_WALLET_BUTTON),
|
||||
onClick = onCreateNewWalletButtonClick,
|
||||
)
|
||||
AddExistingWalletButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.STORIES_SCREEN_ADD_EXISTING_WALLET_BUTTON),
|
||||
onClick = onAddExistingWalletButtonClick,
|
||||
)
|
||||
ScanCardButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.STORIES_SCREEN_SCAN_BUTTON),
|
||||
showProgress = btnScanStateInProgress,
|
||||
onClick = onScanButtonClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateNewWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
StoriesButton(
|
||||
modifier = modifier,
|
||||
text = stringResourceSafe(id = R.string.home_button_create_new_wallet),
|
||||
useDarkerColors = false,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddExistingWalletButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
StoriesButton(
|
||||
modifier = modifier,
|
||||
text = stringResourceSafe(id = R.string.home_button_add_existing_wallet),
|
||||
useDarkerColors = true,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
StoriesButton(
|
||||
modifier = modifier,
|
||||
text = stringResourceSafe(id = R.string.home_button_scan),
|
||||
useDarkerColors = true,
|
||||
icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24),
|
||||
onClick = onClick,
|
||||
showProgress = showProgress,
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun HomeButtonsV2Preview(@PreviewParameter(HomeButtonsV2ParameterProvider::class) state: HomeButtonsV2State) {
|
||||
TangemThemePreview {
|
||||
Box(
|
||||
modifier = Modifier.background(Color.Black),
|
||||
) {
|
||||
HomeButtonsV2(
|
||||
btnScanStateInProgress = state.btnScanStateInProgress,
|
||||
onCreateNewWalletButtonClick = {},
|
||||
onAddExistingWalletButtonClick = {},
|
||||
onScanButtonClick = {},
|
||||
modifier = Modifier.padding(all = TangemTheme.dimens.spacing16),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HomeButtonsV2ParameterProvider : CollectionPreviewParameterProvider<HomeButtonsV2State>(
|
||||
collection = listOf(
|
||||
HomeButtonsV2State(
|
||||
btnScanStateInProgress = false,
|
||||
),
|
||||
HomeButtonsV2State(
|
||||
btnScanStateInProgress = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private data class HomeButtonsV2State(
|
||||
val btnScanStateInProgress: Boolean,
|
||||
)
|
||||
// endregion Preview
|
||||
|
|
@ -7,6 +7,7 @@ import org.rekotlin.StateType
|
|||
// todo refactor [REDACTED_TASK_KEY]
|
||||
data class HomeState(
|
||||
val scanInProgress: Boolean = false,
|
||||
val isV2StoriesEnabled: Boolean = false,
|
||||
val stories: ImmutableList<Stories> = getRestrictedStories().toImmutableList(),
|
||||
) : StateType {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
package com.tangem.common.test.data.quote
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.local.quote.converter.QuoteConverter
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.utils.extensions.orZero
|
||||
|
||||
fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): Quote {
|
||||
return QuoteConverter(source = source).convert(value = mapOf(rawCurrencyId to this).entries.first())
|
||||
fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): QuoteStatus {
|
||||
return QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID(rawCurrencyId),
|
||||
value = QuoteStatus.Data(
|
||||
source = source,
|
||||
fiatRate = price.orZero(),
|
||||
priceChange = priceChange24h.orZero().movePointLeft(2),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun Pair<String, QuotesResponse.Quote>.toDomain(source: StatusSource = StatusSource.ACTUAL): Quote {
|
||||
return QuoteConverter(source = source).convert(value = mapOf(this).entries.first())
|
||||
fun Pair<String, QuotesResponse.Quote>.toDomain(source: StatusSource = StatusSource.ACTUAL): QuoteStatus {
|
||||
return second.toDomain(rawCurrencyId = first, source = source)
|
||||
}
|
||||
|
|
@ -62,5 +62,17 @@
|
|||
{
|
||||
"name": "USEDESK_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "SEND_VIA_SWAP_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "SWAP_REDESIGN_ENABLED",
|
||||
"version": "undefined"
|
||||
},
|
||||
{
|
||||
"name": "SEND_REDESIGN_ENABLED",
|
||||
"version": "undefined"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
|
|||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Trace(
|
||||
@Json(name = "exposed") val exposed: Exposed,
|
||||
@Json(name = "exposed") val exposed: Exposed?,
|
||||
@Json(name = "asset") val asset: NftAsset,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ import com.squareup.moshi.JsonClass
|
|||
data class ValidationResponse(
|
||||
@Json(name = "status") val status: String,
|
||||
@Json(name = "result_type") val resultType: String,
|
||||
@Json(name = "description") val description: String?,
|
||||
)
|
||||
|
|
@ -7,4 +7,5 @@ import com.squareup.moshi.JsonClass
|
|||
data class GenerateNoneByCardWalletRequest(
|
||||
@Json(name = "auth_type") val authType: String = "card_wallet",
|
||||
@Json(name = "card_wallet_address") val cardWalletAddress: String,
|
||||
@Json(name = "card_id") val cardId: String,
|
||||
)
|
||||
|
|
@ -8,4 +8,5 @@ data class GetAccessTokenByCardWalletRequest(
|
|||
@Json(name = "auth_type") val authType: String = "card_wallet",
|
||||
@Json(name = "session_id") val sessionId: String,
|
||||
@Json(name = "signature") val signature: String,
|
||||
@Json(name = "salt") val salt: String,
|
||||
)
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
package com.tangem.datasource.local.quote.converter
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
|
||||
/**
|
||||
* Converter from [QuotesResponse.Quote] to [Quote.Value]
|
||||
*
|
||||
* @property source status source
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class QuoteConverter(
|
||||
private val source: StatusSource,
|
||||
) :
|
||||
Converter<Map.Entry<String, QuotesResponse.Quote>, Quote.Value> {
|
||||
|
||||
/**
|
||||
* Secondary constructor
|
||||
*
|
||||
* @param isCached flag that determines whether the quote is a cache
|
||||
*/
|
||||
constructor(isCached: Boolean) : this(
|
||||
source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL,
|
||||
)
|
||||
|
||||
override fun convert(value: Map.Entry<String, QuotesResponse.Quote>): Quote.Value {
|
||||
val (currencyId, quote) = value
|
||||
|
||||
return Quote.Value(
|
||||
rawCurrencyId = CryptoCurrency.RawID(currencyId),
|
||||
fiatRate = quote.price.orZero(),
|
||||
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -383,6 +383,8 @@
|
|||
<string name="give_permission_unlimited">Unlimited</string>
|
||||
<string name="home_button_order">Order Tangem</string>
|
||||
<string name="home_button_scan">Scan Tangem</string>
|
||||
<string name="home_button_create_new_wallet">Create New Wallet</string>
|
||||
<string name="home_button_add_existing_wallet">Add Existing Wallet</string>
|
||||
<string name="hot_crypto_add_token_subtitle">to %s</string>
|
||||
<string name="hot_crypto_token_network">On %s network</string>
|
||||
<string name="information_generated_with_ai">This information was generated with AI.\nTap here, if you find any errors.</string>
|
||||
|
|
@ -856,8 +858,12 @@
|
|||
<string name="send_recipient">Recipient</string>
|
||||
<string name="send_recipient_address_error">Not a valid address</string>
|
||||
<string name="send_recipient_address_footer">Ensure the receiving wallet address is on the %s network to avoid losing your tokens</string>
|
||||
<string name="send_recipient_address_footer_highlighted_part">send to %s</string>
|
||||
<string name="send_recipient_address_footer_v2">Ensure you %s network address, as errors may result in lost transfers</string>
|
||||
<string name="send_recipient_label">Send to</string>
|
||||
<string name="send_recipient_memo_footer">A Memo/Destination Tag is a unique ID for differentiating transactions sent to the same recipient on the same network. Caution: Omitting a memo may lead to misplaced funds</string>
|
||||
<string name="send_recipient_memo_footer_v2">Memo / Destination Tag is a code separating transactions to a shared recipient in a crypto network.</string>
|
||||
<string name="send_recipient_memo_footer_v2_highlighted">Caution: Missing a memo may lead to fund loss.</string>
|
||||
<string name="send_recipient_wallets_title">My wallets</string>
|
||||
<string name="send_satoshi_per_byte_text">A way of measuring Bitcoin transaction fees. It indicates the number of the smallest Bitcoin unit (Satoshi) for each virtual byte in a transaction. The higher the number, the faster the transaction will be processed by miners.</string>
|
||||
<string name="send_satoshi_per_byte_title">Satoshi / vByte</string>
|
||||
|
|
@ -885,7 +891,7 @@
|
|||
<string name="settings_forget_wallet">Forget wallet</string>
|
||||
<string name="settings_forget_wallet_footer">This will remove the wallet from the application. The wallet itself can be added again.</string>
|
||||
<string name="settings_wallet_name_title">Name</string>
|
||||
<string name="stake_token_description">Join staking with this token</string>
|
||||
<string name="stake_token_description">Put your token to work</string>
|
||||
<string name="staking_amount_requirement_error">The amount to stake must be at least %s</string>
|
||||
<string name="staking_amount_tron_integer_error">Staking amount will be rounded to %1$s TRX due to network rules.</string>
|
||||
<string name="staking_amount_tron_integer_error_unstaking">Unstaking amount will be rounded to %1$s TRX due to network rules.</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.core.ui.components.audits
|
|||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -11,6 +12,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
|
@ -30,41 +32,59 @@ import com.tangem.core.ui.res.TangemThemePreview
|
|||
fun AuditLabel(state: AuditLabelUM, modifier: Modifier = Modifier) {
|
||||
AuditLabelInternal(
|
||||
textReference = state.text,
|
||||
textColor = getColorByType(state.type),
|
||||
backgroundColor = getColorByType(state.type).copy(alpha = 0.1f),
|
||||
type = state.type,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuditLabelInternal(
|
||||
textReference: TextReference,
|
||||
textColor: Color,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
private fun AuditLabelInternal(textReference: TextReference, type: AuditLabelUM.Type, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = backgroundColor,
|
||||
color = getBackgroundColorByType(type),
|
||||
shape = TangemTheme.shapes.roundedCornersSmall2,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = textReference.resolveReference(),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing4),
|
||||
color = getTextColorByType(type),
|
||||
modifier = Modifier.padding(getPaddingByType(type)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
private fun getColorByType(type: AuditLabelUM.Type): Color {
|
||||
private fun getTextColorByType(type: AuditLabelUM.Type): Color {
|
||||
return when (type) {
|
||||
AuditLabelUM.Type.Prohibition -> TangemTheme.colors.text.warning
|
||||
AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention
|
||||
AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent
|
||||
AuditLabelUM.Type.Info -> TangemTheme.colors.text.primary2
|
||||
}
|
||||
}
|
||||
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
private fun getBackgroundColorByType(type: AuditLabelUM.Type): Color {
|
||||
return when (type) {
|
||||
AuditLabelUM.Type.Prohibition -> TangemTheme.colors.text.warning.copy(alpha = 0.1f)
|
||||
AuditLabelUM.Type.Warning -> TangemTheme.colors.text.attention.copy(alpha = 0.1f)
|
||||
AuditLabelUM.Type.Permit -> TangemTheme.colors.text.accent.copy(alpha = 0.1f)
|
||||
AuditLabelUM.Type.Info -> TangemTheme.colors.text.accent
|
||||
}
|
||||
}
|
||||
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
private fun getPaddingByType(type: AuditLabelUM.Type): PaddingValues {
|
||||
return when (type) {
|
||||
AuditLabelUM.Type.Prohibition,
|
||||
AuditLabelUM.Type.Warning,
|
||||
AuditLabelUM.Type.Permit,
|
||||
-> PaddingValues(horizontal = 4.dp)
|
||||
AuditLabelUM.Type.Info -> PaddingValues(horizontal = 6.dp, vertical = 1.dp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,5 +22,8 @@ data class AuditLabelUM(val text: TextReference, val type: Type) {
|
|||
|
||||
/** Blue. Like, "trusted" */
|
||||
Permit,
|
||||
|
||||
/** Blue with white text. Like, "best rate" */
|
||||
Info,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package com.tangem.core.ui.components.badge
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
|
||||
/**
|
||||
* Badge component
|
||||
*
|
||||
* @param iconRes icon drawable resource
|
||||
* @param text text reference
|
||||
* @param modifier composable modifier
|
||||
*
|
||||
* @see <a href="https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=3467-2362&t=SnNQ4qhxwj2mhhra-4">Figma</a>
|
||||
*/
|
||||
@Composable
|
||||
fun Badge(@DrawableRes iconRes: Int, text: TextReference, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier
|
||||
.border(1.dp, TangemTheme.colors.stroke.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = rememberVectorPainter(
|
||||
ImageVector.vectorResource(iconRes),
|
||||
),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
Text(
|
||||
text = text.resolveReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun Badge_Preview() {
|
||||
TangemThemePreview {
|
||||
Badge(
|
||||
iconRes = R.drawable.ic_star_mini_24,
|
||||
text = stringReference("4.9"),
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.core.ui.components.badge.entity
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
/**
|
||||
* Badge component UI model
|
||||
*
|
||||
* @param iconRes icon drawable resource
|
||||
* @param text text reference
|
||||
*/
|
||||
data class BadgeUM(
|
||||
val text: TextReference,
|
||||
@DrawableRes val iconRes: Int,
|
||||
)
|
||||
|
|
@ -16,6 +16,7 @@ enum class TangemButtonSize {
|
|||
RoundedAction,
|
||||
WideAction,
|
||||
TwoLines,
|
||||
Small,
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -28,6 +29,7 @@ internal fun TangemButtonSize.toHeightDp(): Dp = when (this) {
|
|||
TangemButtonSize.RoundedAction,
|
||||
-> TangemTheme.dimens.size36
|
||||
TangemButtonSize.WideAction -> TangemTheme.dimens.size40
|
||||
TangemButtonSize.Small -> 24.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -40,6 +42,7 @@ internal fun TangemButtonSize.toShape(): Shape = when (this) {
|
|||
TangemButtonSize.Action -> TangemTheme.shapes.roundedCornersMedium
|
||||
TangemButtonSize.TwoLines -> TangemTheme.shapes.roundedCornersXMedium
|
||||
TangemButtonSize.RoundedAction -> TangemTheme.shapes.roundedCornersLarge
|
||||
TangemButtonSize.Small -> TangemTheme.shapes.roundedCornersXMedium
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -48,6 +51,7 @@ internal fun TangemButtonSize.toIconPadding(): Dp = when (this) {
|
|||
TangemButtonSize.Default,
|
||||
TangemButtonSize.WideAction,
|
||||
TangemButtonSize.TwoLines,
|
||||
TangemButtonSize.Small,
|
||||
-> TangemTheme.dimens.spacing4
|
||||
TangemButtonSize.Text -> TangemTheme.dimens.spacing8
|
||||
TangemButtonSize.Selector -> 0.dp
|
||||
|
|
@ -100,11 +104,18 @@ internal fun TangemButtonSize.toContentPadding(icon: TangemButtonIconPosition):
|
|||
start = horizontalPadding.first,
|
||||
end = horizontalPadding.second,
|
||||
)
|
||||
TangemButtonSize.Small -> PaddingValues(
|
||||
top = TangemTheme.dimens.spacing4,
|
||||
bottom = TangemTheme.dimens.spacing4,
|
||||
start = horizontalPadding.first,
|
||||
end = horizontalPadding.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
internal fun TangemButtonSize.toHorizontalContentPadding(icon: TangemButtonIconPosition): Pair<Dp, Dp> {
|
||||
return when (this) {
|
||||
TangemButtonSize.Default,
|
||||
|
|
@ -124,5 +135,10 @@ internal fun TangemButtonSize.toHorizontalContentPadding(icon: TangemButtonIconP
|
|||
is TangemButtonIconPosition.Start -> TangemTheme.dimens.spacing16 to TangemTheme.dimens.spacing24
|
||||
is TangemButtonIconPosition.End -> TangemTheme.dimens.spacing24 to TangemTheme.dimens.spacing16
|
||||
}
|
||||
TangemButtonSize.Small -> when (icon) {
|
||||
is TangemButtonIconPosition.None -> TangemTheme.dimens.spacing12 to TangemTheme.dimens.spacing12
|
||||
is TangemButtonIconPosition.Start -> TangemTheme.dimens.spacing8 to TangemTheme.dimens.spacing12
|
||||
is TangemButtonIconPosition.End -> TangemTheme.dimens.spacing12 to TangemTheme.dimens.spacing8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.resolveAnnotatedReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
/**
|
||||
|
|
@ -32,7 +32,7 @@ fun FooterContainer(
|
|||
AnimatedVisibility(visible = footer != null) {
|
||||
val footerWrapped = remember(this) { requireNotNull(footer) }
|
||||
Text(
|
||||
text = footerWrapped.resolveReference(),
|
||||
text = footerWrapped.resolveAnnotatedReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
package com.tangem.core.ui.components.fields
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.utils.StringsSigns.PASSWORD_VISUAL_CHAR
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
fun PinTextField(
|
||||
value: String,
|
||||
length: Int,
|
||||
isPasswordVisual: Boolean,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val textFieldValue = remember(value) {
|
||||
TextFieldValue(value, selection = TextRange(value.length))
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
if (it.text.length <= length) {
|
||||
onValueChange(it.text)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
.clickable {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
.focusRequester(focusRequester),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
singleLine = true,
|
||||
textStyle = TangemTheme.typography.body1,
|
||||
maxLines = 1,
|
||||
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(modifier = Modifier.width(0.dp)) {
|
||||
innerTextField()
|
||||
}
|
||||
CellDecoration(
|
||||
length = length,
|
||||
isPasswordVisual = isPasswordVisual,
|
||||
value = value,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun CellDecoration(
|
||||
length: Int,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isPasswordVisual: Boolean = false,
|
||||
) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val width = textMeasurer.measure("0")
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
repeat(length) {
|
||||
val char = if (it < value.length) {
|
||||
if (isPasswordVisual) PASSWORD_VISUAL_CHAR.toString() else value[it].toString()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = TangemTheme.colors.background.tertiary,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = char,
|
||||
transitionSpec = {
|
||||
(
|
||||
fadeIn(animationSpec = tween(220, delayMillis = 90)) +
|
||||
slideInVertically(animationSpec = tween(330, delayMillis = 0))
|
||||
)
|
||||
.togetherWith(
|
||||
fadeOut(animationSpec = tween(90)) + slideOutVertically(tween(220)),
|
||||
)
|
||||
},
|
||||
) { text ->
|
||||
Text(
|
||||
modifier = Modifier.sizeIn(minWidth = width.size.width.dp + 8.dp, minHeight = 48.dp),
|
||||
text = text,
|
||||
style = TangemTheme.typography.h3,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 48.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun Preview() {
|
||||
TangemThemePreview {
|
||||
var text by remember { mutableStateOf("123") }
|
||||
|
||||
Column {
|
||||
PinTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
isPasswordVisual = false,
|
||||
length = 6,
|
||||
)
|
||||
|
||||
Row {
|
||||
Button(onClick = { text += Random.nextInt(0, 10).toString() }) {
|
||||
Text("Add Character")
|
||||
}
|
||||
Button(onClick = { text = text.dropLast(1) }) {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -141,7 +141,7 @@ fun InputRowEnterInfoAmountV2(
|
|||
)
|
||||
Icon(
|
||||
modifier = Modifier.size(16.dp),
|
||||
painter = painterResource(R.drawable.ic_union_16),
|
||||
painter = painterResource(R.drawable.ic_token_info_24),
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
contentDescription = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ package com.tangem.core.ui.components.inputrow
|
|||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -16,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.buttons.small.TangemIconButton
|
||||
import com.tangem.core.ui.components.fields.SimpleTextField
|
||||
import com.tangem.core.ui.components.icons.identicon.IdentIcon
|
||||
import com.tangem.core.ui.components.inputrow.inner.CrossIcon
|
||||
|
|
@ -25,24 +30,29 @@ import com.tangem.core.ui.extensions.TextReference
|
|||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.utils.DEFAULT_ANIMATION_DURATION
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* [Input Row Recipient](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-826&mode=design&t=IQ5lBJEkFGU4WSvi-4)
|
||||
* * [Design Reference](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-826&mode=design&t=IQ5lBJEkFGU4WSvi-4)
|
||||
*
|
||||
* @param title title reference
|
||||
* @param value recipient address
|
||||
* @param placeholder placeholder
|
||||
* @param onValueChange callback for value change
|
||||
* @param onPasteClick callback for paste
|
||||
* @param modifier composable modifier
|
||||
* @param singleLine is single line text
|
||||
* @param error error text
|
||||
* @param isError is error flag
|
||||
* @param showDivider show divider
|
||||
* @param title The title text reference to display above the input field
|
||||
* @param value The current recipient address value to display in the input field
|
||||
* @param placeholder The placeholder text reference to show when the input is empty
|
||||
* @param onValueChange Callback invoked when the input value changes, receives the new string value
|
||||
* @param onPasteClick Callback invoked when the paste button is clicked, receives the pasted string
|
||||
* @param onQrCodeClick Callback invoked when the QR code scan button is clicked
|
||||
* @param modifier Optional modifier to apply to the composable
|
||||
* @param singleLine Whether the text field should be constrained to a single line (default: false)
|
||||
* @param error Optional error text reference to display when validation fails
|
||||
* @param isError Whether the input is in an error state, affects styling and title color
|
||||
* @param showDivider Whether to show a divider below the component (default: false)
|
||||
* @param isLoading Whether to show a loading indicator instead of the identicon (default: false)
|
||||
* @param isValuePasted Whether the current value was pasted, affects visual feedback (default: false)
|
||||
*
|
||||
* @see InputRowRecipientDefault for readonly version
|
||||
* @see InputRowRecipientDefault for a read-only version of this component
|
||||
*/
|
||||
@Suppress("LongParameterList", "LongMethod")
|
||||
@Composable
|
||||
fun InputRowRecipient(
|
||||
title: TextReference,
|
||||
|
|
@ -50,6 +60,8 @@ fun InputRowRecipient(
|
|||
placeholder: TextReference,
|
||||
onValueChange: (String) -> Unit,
|
||||
onPasteClick: (String) -> Unit,
|
||||
onQrCodeClick: () -> Unit,
|
||||
isRedesignEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
singleLine: Boolean = false,
|
||||
error: TextReference? = null,
|
||||
|
|
@ -109,18 +121,55 @@ fun InputRowRecipient(
|
|||
.padding(start = TangemTheme.dimens.spacing8),
|
||||
)
|
||||
}
|
||||
PasteButton(
|
||||
isPasteButtonVisible = value.isBlank(),
|
||||
onClick = onPasteClick,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(CenterEnd)
|
||||
.padding(start = TangemTheme.dimens.spacing8),
|
||||
)
|
||||
.align(CenterEnd),
|
||||
) {
|
||||
if (isRedesignEnabled) {
|
||||
QrButton(
|
||||
visible = !singleLine,
|
||||
onQrCodeClick = onQrCodeClick,
|
||||
)
|
||||
}
|
||||
PasteButton(
|
||||
isPasteButtonVisible = value.isBlank(),
|
||||
onClick = onPasteClick,
|
||||
backgroundColorEnabled = if (isRedesignEnabled) {
|
||||
TangemTheme.colors.button.secondary
|
||||
} else {
|
||||
TangemTheme.colors.button.primary
|
||||
},
|
||||
textColor = if (isRedesignEnabled) {
|
||||
TangemTheme.colors.text.primary1
|
||||
} else {
|
||||
TangemTheme.colors.text.primary2
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(start = TangemTheme.dimens.spacing8),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrButton(visible: Boolean, onQrCodeClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(animationSpec = tween(DEFAULT_ANIMATION_DURATION)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
TangemIconButton(
|
||||
iconRes = R.drawable.ic_scan_16,
|
||||
onClick = onQrCodeClick,
|
||||
background = TangemTheme.colors.button.secondary,
|
||||
iconTint = TangemTheme.colors.text.primary1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.InputIcon(isLoading: Boolean, value: String) {
|
||||
var isLoadingProxy by remember { mutableStateOf(isLoading) }
|
||||
|
|
@ -172,7 +221,9 @@ private fun InputRowRecipientPreview(
|
|||
showDivider = true,
|
||||
onValueChange = {},
|
||||
onPasteClick = {},
|
||||
onQrCodeClick = {},
|
||||
modifier = Modifier.background(TangemTheme.colors.background.primary),
|
||||
isRedesignEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
|
|
@ -34,14 +35,21 @@ import com.tangem.core.ui.utils.DEFAULT_ANIMATION_DURATION
|
|||
* @param modifier composable modifier
|
||||
*/
|
||||
@Composable
|
||||
fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
fun PasteButton(
|
||||
isPasteButtonVisible: Boolean,
|
||||
onClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColorEnabled: Color = TangemTheme.colors.button.primary,
|
||||
backgroundColorDisabled: Color = TangemTheme.colors.button.secondary,
|
||||
textColor: Color = TangemTheme.colors.text.primary2,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val isPasteEnabled = !clipboardManager.getText()?.text.isNullOrEmpty()
|
||||
val color = if (isPasteEnabled) {
|
||||
TangemTheme.colors.button.primary
|
||||
backgroundColorEnabled
|
||||
} else {
|
||||
TangemTheme.colors.button.secondary
|
||||
backgroundColorDisabled
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = isPasteButtonVisible,
|
||||
|
|
@ -53,7 +61,7 @@ fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifi
|
|||
Text(
|
||||
text = stringResourceSafe(R.string.common_paste),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.text.primary2,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = color,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
package com.tangem.core.ui.components.provider
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.dp
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import androidx.constraintlayout.compose.Visibility
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.audits.AuditLabel
|
||||
import com.tangem.core.ui.components.audits.AuditLabelUM
|
||||
import com.tangem.core.ui.components.badge.Badge
|
||||
import com.tangem.core.ui.components.badge.entity.BadgeUM
|
||||
import com.tangem.core.ui.components.provider.entity.ProviderChooseUM
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Provider Row - Choose Crypto component
|
||||
*
|
||||
* @param providerChooseUM component ui model
|
||||
* @param onClick click listener
|
||||
* @param modifier composable modifier
|
||||
*
|
||||
* @see <a href="https://www.figma.com/design/JJuqr3gX9IC4WBv3C95uqj/Android--Copy-?node-id=3467-2643&t=Tg0OOJTQK9H4KBWL-4">Figma</a>
|
||||
*/
|
||||
@Composable
|
||||
@Suppress("DestructuringDeclarationWithTooManyEntries")
|
||||
fun ProviderChooseCrypto(providerChooseUM: ProviderChooseUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
ConstraintLayout(
|
||||
modifier = modifier
|
||||
.background(TangemTheme.colors.background.action)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.selectedBorder(isSelected = providerChooseUM.isSelected)
|
||||
.clickable(enabled = true, onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
.padding(all = 12.dp),
|
||||
) {
|
||||
val (iconRef, titleRef, badgeRef, infoRef, labelRef) = createRefs()
|
||||
val titleEndRef = createStartBarrier(infoRef, labelRef)
|
||||
|
||||
IconContent(
|
||||
iconUrl = providerChooseUM.iconUrl,
|
||||
modifier = Modifier
|
||||
.constrainAs(iconRef) {
|
||||
start.linkTo(parent.start)
|
||||
top.linkTo(parent.top)
|
||||
bottom.linkTo(parent.bottom)
|
||||
},
|
||||
)
|
||||
TitleContent(
|
||||
providerChooseUM = providerChooseUM,
|
||||
modifier = Modifier.constrainAs(titleRef) {
|
||||
start.linkTo(iconRef.end, 12.dp)
|
||||
end.linkTo(titleEndRef, 4.dp)
|
||||
top.linkTo(parent.top)
|
||||
bottom.linkTo(badgeRef.top)
|
||||
width = Dimension.fillToConstraints
|
||||
},
|
||||
)
|
||||
BadgeContent(
|
||||
providerChooseUM = providerChooseUM,
|
||||
modifier = Modifier.constrainAs(badgeRef) {
|
||||
start.linkTo(iconRef.end, 12.dp)
|
||||
end.linkTo(labelRef.start, 4.dp)
|
||||
top.linkTo(titleRef.bottom, 4.dp)
|
||||
bottom.linkTo(parent.bottom)
|
||||
width = Dimension.fillToConstraints
|
||||
visibility = if (providerChooseUM.badgeList.isEmpty()) {
|
||||
Visibility.Gone
|
||||
} else {
|
||||
Visibility.Visible
|
||||
}
|
||||
},
|
||||
)
|
||||
InfoContent(
|
||||
text = providerChooseUM.infoText,
|
||||
modifier = Modifier.constrainAs(infoRef) {
|
||||
end.linkTo(parent.end)
|
||||
top.linkTo(parent.top)
|
||||
bottom.linkTo(labelRef.top, 4.dp)
|
||||
},
|
||||
)
|
||||
LabelContent(
|
||||
labelUM = providerChooseUM.labelUM,
|
||||
modifier = Modifier.constrainAs(labelRef) {
|
||||
end.linkTo(parent.end)
|
||||
top.linkTo(infoRef.bottom)
|
||||
bottom.linkTo(parent.bottom)
|
||||
visibility = if (providerChooseUM.labelUM == null) {
|
||||
Visibility.Gone
|
||||
} else {
|
||||
Visibility.Visible
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconContent(iconUrl: String, modifier: Modifier = Modifier) {
|
||||
SubcomposeAsyncImage(
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(TangemColorPalette.Light1),
|
||||
model = ImageRequest.Builder(context = LocalContext.current)
|
||||
.data(iconUrl)
|
||||
.crossfade(enable = true)
|
||||
.allowHardware(false)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleContent(providerChooseUM: ProviderChooseUM, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = providerChooseUM.title.resolveReference(),
|
||||
style = TangemTheme.typography.subtitle2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = providerChooseUM.subtitle.resolveReference(),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoContent(text: TextReference, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text.resolveReference(),
|
||||
style = TangemTheme.typography.body2,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeContent(providerChooseUM: ProviderChooseUM, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
providerChooseUM.badgeList.fastForEach { badge ->
|
||||
Badge(
|
||||
iconRes = badge.iconRes,
|
||||
text = badge.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelContent(labelUM: ProviderChooseUM.LabelUM?, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
when (labelUM) {
|
||||
is ProviderChooseUM.LabelUM.Info -> AuditLabel(labelUM.auditLabelUM)
|
||||
is ProviderChooseUM.LabelUM.Text -> Text(
|
||||
text = labelUM.text.resolveReference(),
|
||||
style = TangemTheme.typography.caption2,
|
||||
color = TangemTheme.colors.text.warning,
|
||||
)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun ProviderChooseCrypto_Preview(
|
||||
@PreviewParameter(ProviderChooseCryptoPreviewProvider::class) params: ProviderChooseUM,
|
||||
) {
|
||||
TangemThemePreview {
|
||||
ProviderChooseCrypto(
|
||||
providerChooseUM = params,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ProviderChooseCryptoPreviewProvider : PreviewParameterProvider<ProviderChooseUM> {
|
||||
override val values: Sequence<ProviderChooseUM>
|
||||
get() = sequenceOf(
|
||||
ProviderChooseUM(
|
||||
title = stringReference("ChangeHero"),
|
||||
subtitle = stringReference("CEX"),
|
||||
infoText = stringReference("1 800,00 POL"),
|
||||
iconUrl = "",
|
||||
badgeList = persistentListOf(
|
||||
BadgeUM(
|
||||
text = stringReference("4.9"),
|
||||
iconRes = R.drawable.ic_star_outline_24,
|
||||
),
|
||||
BadgeUM(
|
||||
text = stringReference("5 mins"),
|
||||
iconRes = R.drawable.ic_speed_24,
|
||||
),
|
||||
BadgeUM(
|
||||
text = stringReference("$3.45"),
|
||||
iconRes = R.drawable.ic_gas_24,
|
||||
),
|
||||
),
|
||||
isSelected = true,
|
||||
labelUM = ProviderChooseUM.LabelUM.Info(
|
||||
auditLabelUM = AuditLabelUM(
|
||||
text = resourceReference(R.string.express_provider_best_rate),
|
||||
type = AuditLabelUM.Type.Info,
|
||||
),
|
||||
),
|
||||
),
|
||||
ProviderChooseUM(
|
||||
title = stringReference("Changelly"),
|
||||
subtitle = stringReference("CEX"),
|
||||
infoText = stringReference("1 799,12 POL"),
|
||||
iconUrl = "",
|
||||
badgeList = persistentListOf(
|
||||
BadgeUM(
|
||||
text = stringReference("4.9"),
|
||||
iconRes = R.drawable.ic_star_outline_24,
|
||||
),
|
||||
BadgeUM(
|
||||
text = stringReference("5 mins"),
|
||||
iconRes = R.drawable.ic_speed_24,
|
||||
),
|
||||
BadgeUM(
|
||||
text = stringReference("$3.45"),
|
||||
iconRes = R.drawable.ic_gas_24,
|
||||
),
|
||||
),
|
||||
isSelected = false,
|
||||
labelUM = ProviderChooseUM.LabelUM.Text(
|
||||
text = stringReference("–0.2%"),
|
||||
),
|
||||
),
|
||||
ProviderChooseUM(
|
||||
title = stringReference("Changelly"),
|
||||
subtitle = stringReference("CEX"),
|
||||
infoText = stringReference("1 799,12 POL"),
|
||||
iconUrl = "",
|
||||
badgeList = persistentListOf(),
|
||||
isSelected = false,
|
||||
labelUM = ProviderChooseUM.LabelUM.Text(
|
||||
text = stringReference("–0.2%"),
|
||||
),
|
||||
),
|
||||
ProviderChooseUM(
|
||||
title = stringReference("Changelly"),
|
||||
subtitle = stringReference("CEX"),
|
||||
infoText = stringReference("1 POL"),
|
||||
iconUrl = "",
|
||||
badgeList = persistentListOf(),
|
||||
isSelected = false,
|
||||
labelUM = ProviderChooseUM.LabelUM.Text(
|
||||
text = stringReference("–0.2%"),
|
||||
),
|
||||
),
|
||||
ProviderChooseUM(
|
||||
title = stringReference("Changelly"),
|
||||
subtitle = stringReference("CEX"),
|
||||
infoText = stringReference("1 POL"),
|
||||
iconUrl = "",
|
||||
badgeList = persistentListOf(),
|
||||
isSelected = false,
|
||||
labelUM = null,
|
||||
),
|
||||
ProviderChooseUM(
|
||||
title = stringReference("Changelly"),
|
||||
subtitle = stringReference("CEX"),
|
||||
infoText = stringReference("1 POL"),
|
||||
iconUrl = "",
|
||||
badgeList = persistentListOf(
|
||||
BadgeUM(
|
||||
text = stringReference("4.9"),
|
||||
iconRes = R.drawable.ic_star_outline_24,
|
||||
),
|
||||
BadgeUM(
|
||||
text = stringReference("5 mins"),
|
||||
iconRes = R.drawable.ic_speed_24,
|
||||
),
|
||||
BadgeUM(
|
||||
text = stringReference("$3.45"),
|
||||
iconRes = R.drawable.ic_gas_24,
|
||||
),
|
||||
),
|
||||
isSelected = false,
|
||||
labelUM = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.core.ui.components.provider.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.components.audits.AuditLabelUM
|
||||
import com.tangem.core.ui.components.badge.entity.BadgeUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Provider Row - Choose Crypto component UI Model
|
||||
*
|
||||
* @property title title text
|
||||
* @property subtitle subtitle text
|
||||
* @property infoText info text
|
||||
* @property iconUrl icon url
|
||||
* @property isSelected indicated whether component is selected (border outline)
|
||||
* @property badgeList list of badge components
|
||||
* @property labelUM label component (whether AuditLabel or text)
|
||||
*/
|
||||
data class ProviderChooseUM(
|
||||
val title: TextReference,
|
||||
val subtitle: TextReference,
|
||||
val infoText: TextReference,
|
||||
val iconUrl: String,
|
||||
val isSelected: Boolean,
|
||||
val badgeList: ImmutableList<BadgeUM>,
|
||||
val labelUM: LabelUM?,
|
||||
) {
|
||||
@Immutable
|
||||
sealed class LabelUM {
|
||||
data class Info(
|
||||
val auditLabelUM: AuditLabelUM,
|
||||
) : LabelUM()
|
||||
|
||||
data class Text(
|
||||
val text: TextReference,
|
||||
) : LabelUM()
|
||||
}
|
||||
}
|
||||
|
|
@ -60,4 +60,40 @@ fun AnnotatedString.Builder.appendSpace() = append(" ")
|
|||
|
||||
fun AnnotatedString.Builder.appendColored(text: String, color: Color) = withStyle(SpanStyle(color = color)) {
|
||||
append(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends text from a template string to the AnnotatedString.Builder, replacing a placeholder (default "%s")
|
||||
* with custom styled content provided by a lambda. The lambda allows you to insert styled or complex content
|
||||
* (e.g., colored, bold, or annotated text) at the placeholder position.
|
||||
*
|
||||
* If the placeholder is not found in the template, the function does nothing.
|
||||
*
|
||||
* Example usage:
|
||||
* builder.appendWithStyledPlaceholder(
|
||||
* template = "Ensure you %s network address, as errors may result in lost transfers"
|
||||
* ) {
|
||||
* withStyle(SpanStyle(color = Color.Red, fontWeight = FontWeight.Bold)) {
|
||||
* append("Ethereum")
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @param template The template string containing the placeholder to be replaced.
|
||||
* @param placeholder The placeholder string to be replaced by styled content. Default is "%s".
|
||||
* @param styledContent Lambda to build the styled content to insert at the placeholder position.
|
||||
*/
|
||||
fun AnnotatedString.Builder.appendWithStyledPlaceholder(
|
||||
template: String,
|
||||
placeholder: String = "%s",
|
||||
styledContent: AnnotatedString.Builder.() -> Unit,
|
||||
) {
|
||||
val index = template.indexOf(placeholder)
|
||||
if (index < 0) return
|
||||
// Append before placeholder
|
||||
if (index > 0) append(template.substring(0, index))
|
||||
// Append styled content
|
||||
styledContent()
|
||||
// Append after placeholder
|
||||
val afterIndex = index + placeholder.length
|
||||
if (afterIndex < template.length) append(template.substring(afterIndex))
|
||||
}
|
||||
|
|
@ -1,12 +1,21 @@
|
|||
package com.tangem.core.ui.extensions
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.utils.MultipleClickPreventer
|
||||
|
||||
/**
|
||||
|
|
@ -38,4 +47,51 @@ fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier):
|
|||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally applies a modifier based on a boolean condition.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.conditionalCompose(
|
||||
condition: Boolean,
|
||||
modifier: @Composable Modifier.() -> Modifier = { Modifier },
|
||||
otherModifier: @Composable Modifier.() -> Modifier = { this },
|
||||
): Modifier {
|
||||
return if (condition) {
|
||||
then(modifier(Modifier))
|
||||
} else {
|
||||
otherModifier()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Border with accent outline when selected
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.selectedBorder(
|
||||
isSelected: Boolean,
|
||||
width: Dp = 2.5.dp,
|
||||
color: Color = TangemTheme.colors.text.accent.copy(alpha = 0.1f),
|
||||
radius: Dp = 16.dp,
|
||||
) = conditionalCompose(
|
||||
condition = isSelected,
|
||||
modifier = {
|
||||
border(
|
||||
width = width,
|
||||
color = color,
|
||||
shape = RoundedCornerShape(radius),
|
||||
)
|
||||
.padding(2.5.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = TangemTheme.colors.text.accent,
|
||||
shape = RoundedCornerShape(radius - 2.dp),
|
||||
)
|
||||
.clip(RoundedCornerShape(radius - 2.dp))
|
||||
},
|
||||
otherModifier = {
|
||||
padding(2.dp)
|
||||
.clip(RoundedCornerShape(radius - 2.dp))
|
||||
},
|
||||
)
|
||||
|
|
@ -4,6 +4,8 @@ object TestTags {
|
|||
const val STORIES_SCREEN = "STORIES_SCREEN_CONTAINER"
|
||||
const val STORIES_SCREEN_SCAN_BUTTON = "STORIES_SCREEN_SCAN_BUTTON"
|
||||
const val STORIES_SCREEN_ORDER_BUTTON = "STORIES_SCREEN_ORDER_BUTTON"
|
||||
const val STORIES_SCREEN_CREATE_NEW_WALLET_BUTTON = "STORIES_SCREEN_CREATE_NEW_WALLET_BUTTON"
|
||||
const val STORIES_SCREEN_ADD_EXISTING_WALLET_BUTTON = "STORIES_SCREEN_ADD_EXISTING_WALLET_BUTTON"
|
||||
|
||||
const val MAIN_SCREEN = "MAIN_SCREEN_CONTAINER"
|
||||
const val MAIN_SCREEN_MORE_BUTTON = "MAIN_SCREEN_MORE_BUTTON"
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="18"
|
||||
android:viewportHeight="18">
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:pathData="M9,0.547C8.407,0.547 7.867,0.701 7.283,0.956C6.721,1.201 6.073,1.562 5.271,2.008L2.073,3.788C1.611,4.045 1.214,4.374 0.939,4.825C0.662,5.278 0.547,5.793 0.547,6.357L0.547,6.411C0.546,6.602 0.546,6.825 0.6,7.027C0.733,7.53 1.055,7.86 1.448,8.035C1.663,8.13 1.887,8.175 2.088,8.194V13.66C1.167,14.188 0.547,15.181 0.547,16.318C0.547,16.945 1.055,17.453 1.682,17.453H8.23C8.644,17.453 8.98,17.117 8.98,16.703C8.98,16.289 8.644,15.953 8.23,15.953H2.09C2.254,15.267 2.872,14.757 3.608,14.757H8.23C8.644,14.757 8.98,14.421 8.98,14.007C8.98,13.593 8.644,13.257 8.23,13.257H6.669V8.21H15.572C15.834,8.21 16.205,8.19 16.553,8.035C16.946,7.86 17.268,7.53 17.401,7.027C17.455,6.825 17.454,6.602 17.454,6.411L17.454,6.357C17.454,5.793 17.339,5.278 17.062,4.825C16.787,4.374 16.39,4.045 15.928,3.788L12.73,2.008C11.928,1.562 11.28,1.201 10.718,0.956C10.134,0.701 9.594,0.547 9,0.547ZM5.968,3.337C6.81,2.868 7.395,2.543 7.883,2.33C8.356,2.124 8.685,2.047 9,2.047C9.316,2.047 9.645,2.124 10.118,2.33C10.606,2.543 11.191,2.868 12.033,3.337L15.198,5.098C15.506,5.269 15.679,5.437 15.782,5.606C15.883,5.772 15.954,6 15.954,6.357C15.954,6.489 15.954,6.563 15.951,6.617C15.95,6.635 15.95,6.646 15.949,6.652C15.948,6.657 15.946,6.66 15.945,6.663L15.942,6.664C15.923,6.673 15.884,6.686 15.817,6.695C15.75,6.705 15.67,6.71 15.572,6.71H2.429C2.331,6.71 2.251,6.705 2.184,6.695C2.116,6.686 2.078,6.673 2.059,6.664L2.056,6.663C2.055,6.66 2.053,6.657 2.052,6.652C2.051,6.646 2.051,6.635 2.05,6.617C2.047,6.563 2.047,6.489 2.047,6.357C2.047,6 2.118,5.772 2.219,5.606C2.322,5.437 2.495,5.269 2.802,5.098L5.968,3.337ZM3.608,13.257L3.588,13.257V8.21H5.169V13.257H3.608Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
android:fillColor="#0099FF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M12,3.547C11.407,3.547 10.866,3.701 10.283,3.956C9.721,4.201 9.073,4.562 8.271,5.008L5.073,6.788C4.611,7.045 4.214,7.374 3.939,7.825C3.662,8.278 3.547,8.793 3.547,9.357L3.547,9.411C3.546,9.602 3.546,9.825 3.599,10.027C3.733,10.53 4.055,10.86 4.448,11.035C4.663,11.13 4.887,11.175 5.088,11.194V16.66C4.167,17.188 3.547,18.181 3.547,19.318C3.547,19.945 4.055,20.453 4.682,20.453H11.23C11.644,20.453 11.98,20.117 11.98,19.703C11.98,19.289 11.644,18.953 11.23,18.953H5.09C5.254,18.267 5.872,17.757 6.608,17.757H11.23C11.644,17.757 11.98,17.421 11.98,17.007C11.98,16.593 11.644,16.257 11.23,16.257H9.669V11.21H18.572C18.834,11.21 19.205,11.19 19.553,11.035C19.946,10.86 20.268,10.53 20.401,10.027C20.455,9.825 20.454,9.602 20.454,9.411L20.454,9.357C20.454,8.793 20.339,8.278 20.062,7.825C19.787,7.374 19.39,7.045 18.928,6.788L15.73,5.008C14.928,4.562 14.28,4.201 13.718,3.956C13.134,3.701 12.594,3.547 12,3.547ZM8.968,6.337C9.81,5.868 10.395,5.543 10.883,5.33C11.356,5.124 11.685,5.047 12,5.047C12.316,5.047 12.645,5.124 13.118,5.33C13.606,5.543 14.191,5.868 15.033,6.337L18.198,8.099C18.506,8.269 18.679,8.437 18.782,8.606C18.883,8.772 18.954,9 18.954,9.357C18.954,9.489 18.954,9.563 18.951,9.617C18.95,9.635 18.949,9.646 18.949,9.652C18.948,9.657 18.946,9.66 18.945,9.663L18.942,9.664C18.923,9.673 18.884,9.686 18.817,9.695C18.75,9.705 18.67,9.71 18.572,9.71H5.429C5.331,9.71 5.251,9.705 5.184,9.695C5.116,9.686 5.078,9.673 5.059,9.664L5.056,9.663C5.055,9.66 5.053,9.657 5.052,9.652C5.051,9.646 5.051,9.635 5.05,9.617C5.047,9.563 5.047,9.489 5.047,9.357C5.047,9 5.118,8.772 5.219,8.606C5.322,8.437 5.495,8.269 5.802,8.099L8.968,6.337ZM6.608,16.257L6.588,16.257V11.21H8.169V16.257H6.608Z" />
|
||||
|
||||
<path
|
||||
android:pathData="M10.396,15.788C10.103,16.081 10.103,16.556 10.396,16.849C10.689,17.142 11.164,17.142 11.457,16.849L16.849,11.456C17.142,11.163 17.142,10.689 16.849,10.396C16.556,10.103 16.081,10.103 15.788,10.396L10.396,15.788Z"
|
||||
android:fillColor="#000000"/>
|
||||
android:fillColor="#0099FF"
|
||||
android:pathData="M13.396,18.788C13.103,19.081 13.103,19.556 13.396,19.849C13.689,20.142 14.164,20.142 14.457,19.849L19.849,14.456C20.142,14.163 20.142,13.689 19.849,13.396C19.556,13.103 19.081,13.103 18.788,13.396L13.396,18.788Z" />
|
||||
|
||||
<path
|
||||
android:pathData="M10.176,11.503C10.176,10.77 10.77,10.176 11.504,10.176C12.237,10.176 12.832,10.77 12.832,11.503C12.832,12.237 12.237,12.831 11.504,12.831C10.77,12.831 10.176,12.237 10.176,11.503ZM11.504,11.676C11.599,11.676 11.676,11.599 11.676,11.503C11.676,11.408 11.599,11.331 11.504,11.331C11.409,11.331 11.332,11.408 11.332,11.503C11.332,11.599 11.409,11.676 11.504,11.676Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
android:fillColor="#0099FF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M13.176,14.503C13.176,13.77 13.77,13.176 14.504,13.176C15.237,13.176 15.832,13.77 15.832,14.503C15.832,15.237 15.237,15.831 14.504,15.831C13.77,15.831 13.176,15.237 13.176,14.503ZM14.504,14.676C14.599,14.676 14.676,14.599 14.676,14.503C14.676,14.408 14.599,14.331 14.504,14.331C14.409,14.331 14.332,14.408 14.332,14.503C14.332,14.599 14.409,14.676 14.504,14.676Z" />
|
||||
|
||||
<path
|
||||
android:pathData="M15.741,14.412C15.008,14.412 14.413,15.007 14.413,15.74C14.413,16.473 15.008,17.068 15.741,17.068C16.474,17.068 17.069,16.473 17.069,15.74C17.069,15.007 16.474,14.412 15.741,14.412ZM15.913,15.74C15.913,15.835 15.836,15.912 15.741,15.912C15.646,15.912 15.569,15.835 15.569,15.74C15.569,15.645 15.646,15.568 15.741,15.568C15.836,15.568 15.913,15.645 15.913,15.74Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
android:fillColor="#0099FF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M18.741,17.412C18.008,17.412 17.413,18.007 17.413,18.74C17.413,19.473 18.008,20.068 18.741,20.068C19.474,20.068 20.069,19.473 20.069,18.74C20.069,18.007 19.474,17.412 18.741,17.412ZM18.913,18.74C18.913,18.835 18.836,18.912 18.741,18.912C18.646,18.912 18.569,18.835 18.569,18.74C18.569,18.645 18.646,18.568 18.741,18.568C18.836,18.568 18.913,18.645 18.913,18.74Z" />
|
||||
|
||||
</vector>
|
||||
|
|
|
|||
9
core/ui/src/main/res/drawable/ic_gas_24.xml
Normal file
9
core/ui/src/main/res/drawable/ic_gas_24.xml
Normal 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="M19.77,7.23L19.78,7.22L16.06,3.5L15,4.56L17.11,6.67C16.17,7.03 15.5,7.93 15.5,9C15.5,10.38 16.62,11.5 18,11.5C18.36,11.5 18.69,11.42 19,11.29V18.5C19,19.05 18.55,19.5 18,19.5C17.45,19.5 17,19.05 17,18.5V14C17,12.9 16.1,12 15,12H14V5C14,3.9 13.1,3 12,3H6C4.9,3 4,3.9 4,5V21H14V13.5H15.5V18.5C15.5,19.88 16.62,21 18,21C19.38,21 20.5,19.88 20.5,18.5V9C20.5,8.31 20.22,7.68 19.77,7.23ZM12,13.5V19H6V12H12V13.5ZM12,10H6V5H12V10ZM18,10C17.45,10 17,9.55 17,9C17,8.45 17.45,8 18,8C18.55,8 19,8.45 19,9C19,9.55 18.55,10 18,10Z"
|
||||
android:fillColor="#1E1E1E"/>
|
||||
</vector>
|
||||
10
core/ui/src/main/res/drawable/ic_scan_16.xml
Normal file
10
core/ui/src/main/res/drawable/ic_scan_16.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="17dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="17">
|
||||
<path
|
||||
android:pathData="M6.666,2.391H4C2.895,2.391 2,3.287 2,4.391V7.391L3.333,7.391V4.391C3.333,4.023 3.631,3.725 4,3.725H6.666V2.391ZM2,10.391V12.391C2,13.496 2.895,14.391 4,14.391H6.666V13.058H4C3.631,13.058 3.333,12.759 3.333,12.391V10.391H12.666V12.391C12.666,12.759 12.368,13.058 12,13.058H9.333V14.391H12C13.104,14.391 14,13.496 14,12.391V10.391H14.666V9.058H1.333V10.391H2ZM14,4.391V7.391H12.666V4.391C12.666,4.023 12.368,3.725 12,3.725H9.333V2.391H12C13.104,2.391 14,3.287 14,4.391Z"
|
||||
android:fillColor="#1E1E1E"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
10
core/ui/src/main/res/drawable/ic_speed_24.xml
Normal file
10
core/ui/src/main/res/drawable/ic_speed_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<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="M19.15,10.42L20.38,8.57L20.39,8.56C21.393,10.106 21.948,11.9 21.996,13.742C22.044,15.584 21.581,17.404 20.66,19C20.484,19.305 20.23,19.558 19.924,19.734C19.619,19.91 19.272,20.001 18.92,20H5.07C4.721,19.998 4.379,19.904 4.077,19.729C3.775,19.554 3.524,19.302 3.35,19C2.24,17.057 1.813,14.799 2.138,12.585C2.463,10.371 3.522,8.33 5.143,6.789C6.765,5.248 8.857,4.295 11.085,4.083C13.312,3.871 15.547,4.412 17.43,5.62L15.58,6.85C14.042,6.081 12.297,5.827 10.604,6.128C8.91,6.428 7.359,7.266 6.179,8.517C5,9.769 4.255,11.367 4.055,13.075C3.855,14.783 4.211,16.51 5.07,18H18.93C19.591,16.853 19.957,15.561 19.995,14.238C20.034,12.915 19.743,11.603 19.15,10.42ZM11.239,15.844C10.996,15.743 10.776,15.596 10.59,15.41C10.404,15.224 10.257,15.004 10.156,14.761C10.055,14.518 10.003,14.258 10.003,13.995C10.003,13.732 10.055,13.472 10.156,13.229C10.257,12.986 10.404,12.766 10.59,12.58L19.08,6.92L13.42,15.41C13.234,15.596 13.014,15.743 12.771,15.844C12.528,15.945 12.268,15.997 12.005,15.997C11.742,15.997 11.482,15.945 11.239,15.844Z"
|
||||
android:fillColor="#1E1E1E"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
||||
9
core/ui/src/main/res/drawable/ic_star_outline_24.xml
Normal file
9
core/ui/src/main/res/drawable/ic_star_outline_24.xml
Normal 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="M22,9.24L14.81,8.62L12,2L9.19,8.63L2,9.24L7.46,13.97L5.82,21L12,17.27L18.18,21L16.55,13.97L22,9.24ZM12,15.4L8.24,17.67L9.24,13.39L5.92,10.51L10.3,10.13L12,6.1L13.71,10.14L18.09,10.52L14.77,13.4L15.77,17.68L12,15.4Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
13
core/ui/src/main/res/drawable/ic_token_info_24.xml
Normal file
13
core/ui/src/main/res/drawable/ic_token_info_24.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#1E1E1E"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M12,2.25C9.414,2.25 6.934,3.277 5.106,5.106C3.277,6.934 2.25,9.414 2.25,12C2.25,14.586 3.277,17.066 5.106,18.894C6.934,20.723 9.414,21.75 12,21.75C14.586,21.75 17.066,20.723 18.894,18.894C20.723,17.066 21.75,14.586 21.75,12C21.75,9.414 20.723,6.934 18.894,5.106C17.066,3.277 14.586,2.25 12,2.25ZM6.166,6.166C7.714,4.619 9.812,3.75 12,3.75C14.188,3.75 16.287,4.619 17.834,6.166C19.381,7.714 20.25,9.812 20.25,12C20.25,14.188 19.381,16.287 17.834,17.834C16.287,19.381 14.188,20.25 12,20.25C9.812,20.25 7.714,19.381 6.166,17.834C4.619,16.287 3.75,14.188 3.75,12C3.75,9.812 4.619,7.714 6.166,6.166ZM12,9C12.69,9 13.25,8.44 13.25,7.75C13.25,7.06 12.69,6.5 12,6.5C11.31,6.5 10.75,7.06 10.75,7.75C10.75,8.44 11.31,9 12,9ZM10.5,17.5C10.301,17.5 10.11,17.421 9.97,17.28C9.829,17.14 9.75,16.949 9.75,16.75C9.75,16.551 9.829,16.36 9.97,16.22C10.11,16.079 10.301,16 10.5,16H11.25V11.5H11C10.801,11.5 10.61,11.421 10.47,11.28C10.329,11.14 10.25,10.949 10.25,10.75C10.25,10.551 10.329,10.36 10.47,10.22C10.61,10.079 10.801,10 11,10H12C12.199,10 12.39,10.079 12.53,10.22C12.671,10.36 12.75,10.551 12.75,10.75V16H13.5C13.699,16 13.89,16.079 14.03,16.22C14.171,16.36 14.25,16.551 14.25,16.75C14.25,16.949 14.171,17.14 14.03,17.28C13.89,17.421 13.699,17.5 13.5,17.5H12H10.5Z" />
|
||||
|
||||
</vector>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="16">
|
||||
|
||||
<path
|
||||
android:fillColor="#919191"
|
||||
android:pathData="M8,1.5C9.724,1.5 11.378,2.184 12.597,3.403C13.816,4.622 14.5,6.276 14.5,8C14.5,9.724 13.816,11.378 12.597,12.597C11.378,13.816 9.724,14.5 8,14.5C6.276,14.5 4.622,13.816 3.403,12.597C2.184,11.378 1.5,9.724 1.5,8C1.5,6.276 2.184,4.622 3.403,3.403C4.622,2.184 6.276,1.5 8,1.5ZM8,2.5C6.541,2.5 5.143,3.08 4.111,4.111C3.08,5.143 2.5,6.541 2.5,8C2.5,9.459 3.08,10.857 4.111,11.889C5.143,12.92 6.541,13.5 8,13.5C9.459,13.5 10.857,12.92 11.889,11.889C12.92,10.857 13.5,9.459 13.5,8C13.5,6.541 12.92,5.143 11.889,4.111C10.857,3.08 9.459,2.5 8,2.5ZM8,6.667C8.133,6.667 8.26,6.72 8.354,6.813C8.447,6.907 8.5,7.034 8.5,7.167V10.667H9C9.133,10.667 9.26,10.72 9.354,10.814C9.447,10.907 9.5,11.035 9.5,11.167C9.5,11.3 9.447,11.427 9.354,11.521C9.26,11.614 9.133,11.667 9,11.667H7C6.867,11.667 6.74,11.614 6.646,11.521C6.553,11.427 6.5,11.3 6.5,11.167C6.5,11.035 6.553,10.907 6.646,10.814C6.74,10.72 6.867,10.667 7,10.667H7.5V7.667H7.333C7.201,7.667 7.073,7.614 6.979,7.521C6.886,7.427 6.833,7.299 6.833,7.167C6.833,7.035 6.886,6.907 6.979,6.813C7.073,6.72 7.201,6.667 7.333,6.667H8ZM8,4.333C8.46,4.333 8.833,4.707 8.833,5.167C8.833,5.627 8.46,6 8,6C7.54,6 7.167,5.627 7.167,5.167C7.167,4.707 7.54,4.333 8,4.333Z" />
|
||||
|
||||
</vector>
|
||||
|
|
@ -13,4 +13,5 @@ object StringsSigns {
|
|||
const val NON_BREAKING_SPACE = '\u00A0'
|
||||
const val PERCENT = "%"
|
||||
const val THREE_STARS = "\u2217\u2217\u2217"
|
||||
const val PASSWORD_VISUAL_CHAR = '\u2022'
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ internal object BlockAidMapper {
|
|||
} else {
|
||||
mapSimulationSuccessResult(from.simulation.accountSummary)
|
||||
},
|
||||
description = from.validation.description,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class BlockAidMapperTest {
|
|||
spenders = mapOf("spender" to spenderDetails),
|
||||
)
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(
|
||||
|
|
@ -79,7 +79,7 @@ class BlockAidMapperTest {
|
|||
outTransfer = listOf(Transfer(value = "1.5", rawValue = "0x2")),
|
||||
)
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(
|
||||
|
|
@ -105,7 +105,7 @@ class BlockAidMapperTest {
|
|||
@Test
|
||||
fun `when response error validation rhen returns failed to validate`() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Error", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Error", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null),
|
||||
|
|
@ -120,7 +120,7 @@ class BlockAidMapperTest {
|
|||
@Test
|
||||
fun `when response not benign then returns validation unsafe`() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Phishing"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Phishing", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null),
|
||||
|
|
@ -134,7 +134,7 @@ class BlockAidMapperTest {
|
|||
@Test
|
||||
fun `when response simulation not success then returns simulation failed ro simulate`() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Error",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null),
|
||||
|
|
@ -148,7 +148,7 @@ class BlockAidMapperTest {
|
|||
@Test
|
||||
fun `when response simulation is empty then returns failed to simulate`() {
|
||||
val txResponse = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(
|
||||
|
|
|
|||
36
data/express/build.gradle.kts
Normal file
36
data/express/build.gradle.kts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.express"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.express)
|
||||
implementation(projects.domain.wallets.models)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.timber)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.data.express
|
||||
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.express.converter.ExpressProviderConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.exchangeservice.swap.ExpressUtils
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.ExpressRepository
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import timber.log.Timber
|
||||
|
||||
internal class DefaultExpressRepository(
|
||||
private val tangemExpressApi: TangemExpressApi,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) : ExpressRepository {
|
||||
|
||||
override suspend fun getProviders(userWallet: UserWallet): List<ExpressProvider> {
|
||||
return safeApiCall(
|
||||
call = {
|
||||
tangemExpressApi.getProviders(
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
),
|
||||
).getOrThrow().map(ExpressProviderConverter()::convert)
|
||||
},
|
||||
onError = {
|
||||
Timber.w(it, "Unable to fetch express providers")
|
||||
throw it
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.tangem.data.express.converter
|
||||
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.datasource.api.express.models.response.ExpressError as ExpressErrorDTO
|
||||
|
||||
class ExpressErrorConverter(
|
||||
private val jsonAdapter: JsonAdapter<ExpressErrorResponse>,
|
||||
) : Converter<String, ExpressError> {
|
||||
|
||||
@Suppress("MagicNumber", "CyclomaticComplexMethod")
|
||||
override fun convert(value: String): ExpressError {
|
||||
try {
|
||||
val error: ExpressErrorDTO = jsonAdapter.fromJson(value)?.error ?: return ExpressError.UnknownError
|
||||
|
||||
return when (error.code) {
|
||||
2010 -> ExpressError.BadRequest(code = error.code)
|
||||
2020 -> ExpressError.Forbidden(code = error.code)
|
||||
|
||||
2200 -> ExpressError.InternalError(code = error.code)
|
||||
2210 -> ExpressError.ProviderNotFoundError(code = error.code)
|
||||
2220 -> ExpressError.ProviderNotActiveError(code = error.code)
|
||||
2230 -> ExpressError.ProviderNotAvailableError(code = error.code)
|
||||
2231 -> ExpressError.ProviderInternalError(code = error.code)
|
||||
2240 -> ExpressError.ExchangeNotPossibleError(code = error.code)
|
||||
2250 -> error.toTooSmallAmountError()
|
||||
2251 -> error.toTooBigAmountError()
|
||||
2260 -> error.toNotEnoughAllowanceError()
|
||||
2270 -> ExpressError.NotEnoughBalanceError(code = error.code)
|
||||
2280 -> ExpressError.InvalidAddressError(code = error.code)
|
||||
2290 -> error.toInvalidFromDecimalsError()
|
||||
|
||||
2320 -> error.toProviderDifferentAmountError()
|
||||
else -> ExpressError.DataError(error.code, error.description)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return ExpressError.UnknownError
|
||||
}
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toTooSmallAmountError(): ExpressError {
|
||||
val minAmount = value?.minAmount ?: return ExpressError.DataError(code, description)
|
||||
val decimals = value?.decimals ?: return ExpressError.DataError(code, description)
|
||||
val requiredAmount = minAmount.toBigDecimalOrNull()?.movePointLeft(decimals)
|
||||
?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.AmountError.TooSmallError(code = code, amount = requiredAmount)
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toTooBigAmountError(): ExpressError {
|
||||
val maxAmount = value?.maxAmount ?: return ExpressError.DataError(code, description)
|
||||
val decimals = value?.decimals ?: return ExpressError.DataError(code, description)
|
||||
val requiredAmount = maxAmount.toBigDecimalOrNull()?.movePointLeft(decimals)
|
||||
?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.AmountError.TooBigError(code = code, amount = requiredAmount)
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toNotEnoughAllowanceError(): ExpressError {
|
||||
val currentAllowance = value?.currentAllowance ?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.AmountError.NotEnoughAllowanceError(code = code, amount = currentAllowance)
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toInvalidFromDecimalsError(): ExpressError {
|
||||
val receivedFromDecimals = value?.receivedFromDecimals ?: return ExpressError.DataError(code, description)
|
||||
val expressFromDecimals = value?.expressFromDecimals ?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.InvalidFromDecimalsError(
|
||||
code = code,
|
||||
receivedFromDecimals = receivedFromDecimals,
|
||||
expressFromDecimals = expressFromDecimals,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toProviderDifferentAmountError(): ExpressError {
|
||||
val decimals = value?.decimals ?: return ExpressError.DataError(code, description)
|
||||
val fromAmount = value?.fromAmount?.toBigDecimalOrNull() ?: return ExpressError.DataError(code, description)
|
||||
val fromAmountProvider = value?.fromAmountProvider?.toBigDecimalOrNull()
|
||||
?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.ProviderDifferentAmountError(
|
||||
code = code,
|
||||
decimals = decimals,
|
||||
fromAmount = fromAmount.movePointLeft(decimals),
|
||||
fromProviderAmount = fromAmountProvider.movePointLeft(decimals),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.data.express.converter
|
||||
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeProvider
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeProviderType
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressProviderType
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class ExpressProviderConverter : Converter<ExchangeProvider, ExpressProvider> {
|
||||
|
||||
override fun convert(value: ExchangeProvider): ExpressProvider {
|
||||
return ExpressProvider(
|
||||
providerId = value.id,
|
||||
rateTypes = emptyList(),
|
||||
name = value.name,
|
||||
type = convertExchangeType(value.type),
|
||||
imageLarge = value.imageLargeUrl,
|
||||
termsOfUse = value.termsOfUse,
|
||||
privacyPolicy = value.privacyPolicy,
|
||||
isRecommended = value.isRecommended,
|
||||
slippage = value.slippage,
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertExchangeType(type: ExchangeProviderType): ExpressProviderType {
|
||||
return when (type) {
|
||||
ExchangeProviderType.DEX -> ExpressProviderType.DEX
|
||||
ExchangeProviderType.CEX -> ExpressProviderType.CEX
|
||||
ExchangeProviderType.DEX_BRIDGE -> ExpressProviderType.DEX_BRIDGE
|
||||
ExchangeProviderType.ONRAMP -> ExpressProviderType.ONRAMP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.data.express.di
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.express.DefaultExpressRepository
|
||||
import com.tangem.data.express.converter.ExpressErrorConverter
|
||||
import com.tangem.data.express.error.DefaultExpressErrorResolver
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.express.ExpressErrorResolver
|
||||
import com.tangem.domain.express.ExpressRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object ExpressDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExpressErrorResolver(@NetworkMoshi moshi: Moshi): ExpressErrorResolver {
|
||||
val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java)
|
||||
return DefaultExpressErrorResolver(
|
||||
ExpressErrorConverter(jsonAdapter),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExpressRepository(
|
||||
tangemExpressApi: TangemExpressApi,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
): ExpressRepository {
|
||||
return DefaultExpressRepository(
|
||||
tangemExpressApi = tangemExpressApi,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.express.error
|
||||
|
||||
import com.tangem.data.express.converter.ExpressErrorConverter
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.domain.express.ExpressErrorResolver
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
|
||||
internal class DefaultExpressErrorResolver(
|
||||
private val expressErrorConverter: ExpressErrorConverter,
|
||||
) : ExpressErrorResolver {
|
||||
|
||||
override fun resolve(throwable: Throwable): ExpressError {
|
||||
return when (throwable) {
|
||||
is ApiResponseError.HttpException -> {
|
||||
expressErrorConverter.convert(throwable.errorBody.orEmpty())
|
||||
}
|
||||
else -> ExpressError.UnknownError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,7 +80,7 @@ internal class DefaultNetworksStatusesStore(
|
|||
ifNotFound: (Network.ID) -> SimpleNetworkStatus?,
|
||||
) {
|
||||
if (networks.isEmpty()) {
|
||||
Timber.d("Nothing to update: networks is empty")
|
||||
Timber.d("Nothing to update: networks are empty")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import com.tangem.datasource.api.tangemTech.models.HotCryptoResponse
|
|||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.onramp.model.HotCryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.converter.Converter
|
||||
import java.math.BigDecimal
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ internal class HotCryptoCurrencyConverter(
|
|||
|
||||
return HotCryptoCurrency(
|
||||
cryptoCurrency = currency.setupIconUrl(id = rawId.value),
|
||||
quote = createQuote(
|
||||
quoteStatus = createQuote(
|
||||
fiatRate = value.currentPrice,
|
||||
priceChange = value.priceChangePercentage,
|
||||
rawCurrencyId = rawId,
|
||||
|
|
@ -89,16 +89,18 @@ internal class HotCryptoCurrencyConverter(
|
|||
fiatRate: BigDecimal?,
|
||||
priceChange: BigDecimal?,
|
||||
rawCurrencyId: CryptoCurrency.RawID,
|
||||
): Quote {
|
||||
): QuoteStatus {
|
||||
return if (fiatRate != null && priceChange != null) {
|
||||
Quote.Value(
|
||||
QuoteStatus(
|
||||
rawCurrencyId = rawCurrencyId,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = priceChange.movePointLeft(2),
|
||||
source = StatusSource.ACTUAL, // It doesn't matter
|
||||
value = QuoteStatus.Data(
|
||||
fiatRate = fiatRate,
|
||||
priceChange = priceChange.movePointLeft(2),
|
||||
source = StatusSource.ACTUAL, // It doesn't matter
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Quote.Empty(rawCurrencyId)
|
||||
QuoteStatus(rawCurrencyId = rawCurrencyId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,29 +9,50 @@ android {
|
|||
namespace = "com.tangem.data.quotes"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.tokens)
|
||||
// endregion
|
||||
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// region Project - Domain
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.quotes)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.timber)
|
||||
// endregion
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.tangem.data.quotes
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.quotes.QuotesRepositoryV2
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of [QuotesRepositoryV2]
|
||||
*
|
||||
* @property quotesStore quotes store
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultQuotesRepositoryV2 @Inject constructor(
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
) : QuotesRepositoryV2 {
|
||||
|
||||
override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set<CryptoCurrency.RawID>): Set<Quote>? {
|
||||
return quotesStore.getAllSyncOrNull()?.mapTo(hashSetOf()) {
|
||||
it.takeIf { it.rawCurrencyId in currenciesIds } ?: Quote.Empty(it.rawCurrencyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.data.quotes.converter
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
|
||||
/**
|
||||
* Converter from [QuotesResponse.Quote] to [QuoteStatus]
|
||||
*
|
||||
* @property source status source
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuoteStatusConverter(
|
||||
private val source: StatusSource,
|
||||
) : Converter<Map.Entry<String, QuotesResponse.Quote>, QuoteStatus> {
|
||||
|
||||
override fun convert(value: Map.Entry<String, QuotesResponse.Quote>): QuoteStatus {
|
||||
val (currencyId, quote) = value
|
||||
|
||||
return QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID(currencyId),
|
||||
value = QuoteStatus.Data(
|
||||
source = source,
|
||||
fiatRate = quote.price.orZero(),
|
||||
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater
|
||||
import com.tangem.data.quotes.single.DefaultSingleQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.domain.quotes.single.SingleQuoteFetcher
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuoteFetcherModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindMultiQuoteFetcher(impl: DefaultMultiQuoteFetcher): MultiQuoteFetcher
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindMultiQuoteUpdater(impl: DefaultMultiQuoteUpdater): MultiQuoteUpdater
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSingleQuoteFetcher(impl: DefaultSingleQuoteFetcher): SingleQuoteFetcher
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.data.quotes.single.DefaultSingleQuoteProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuoteProducerFactoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSingleQuoteProducerFactory(impl: DefaultSingleQuoteProducer.Factory): SingleQuoteProducer.Factory
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteStatusFetcher
|
||||
import com.tangem.data.quotes.single.DefaultSingleQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuoteStatusFetcherModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindMultiQuoteStatusFetcher(impl: DefaultMultiQuoteStatusFetcher): MultiQuoteStatusFetcher
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSingleQuoteStatusFetcher(impl: DefaultSingleQuoteStatusFetcher): SingleQuoteStatusFetcher
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.data.quotes.single.DefaultSingleQuoteStatusProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuoteStatusProducerFactoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSingleQuoteStatusProducerFactory(
|
||||
impl: DefaultSingleQuoteStatusProducer.Factory,
|
||||
): SingleQuoteStatusProducer.Factory
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteSupplier
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -10,12 +10,12 @@ import javax.inject.Singleton
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object QuoteSupplierModule {
|
||||
internal object QuoteStatusSupplierModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSingleQuoteSupplier(factory: SingleQuoteProducer.Factory): SingleQuoteSupplier {
|
||||
return object : SingleQuoteSupplier(
|
||||
fun provideSingleQuoteStatusSupplier(factory: SingleQuoteStatusProducer.Factory): SingleQuoteStatusSupplier {
|
||||
return object : SingleQuoteStatusSupplier(
|
||||
factory = factory,
|
||||
keyCreator = { "single_quote_${it.rawCurrencyId.value}" },
|
||||
) {}
|
||||
|
|
@ -4,15 +4,19 @@ import android.content.Context
|
|||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.quotes.DefaultQuotesRepositoryV2
|
||||
import com.tangem.data.quotes.store.DefaultQuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater
|
||||
import com.tangem.data.quotes.repository.DefaultQuotesRepository
|
||||
import com.tangem.data.quotes.store.DefaultQuotesStatusesStore
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.domain.quotes.QuotesRepositoryV2
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -33,8 +37,8 @@ internal object QuotesDataModule {
|
|||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): QuotesStoreV2 {
|
||||
return DefaultQuotesStoreV2(
|
||||
): QuotesStatusesStore {
|
||||
return DefaultQuotesStatusesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
persistenceDataStore = DataStoreFactory.create(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
|
|
@ -51,5 +55,23 @@ internal object QuotesDataModule {
|
|||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providesQuotesRepositoryV2(impl: DefaultQuotesRepositoryV2): QuotesRepositoryV2 = impl
|
||||
fun providesQuotesRepository(quotesStatusesStore: QuotesStatusesStore): QuotesRepository {
|
||||
return DefaultQuotesRepository(quotesStatusesStore = quotesStatusesStore)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun bindMultiQuoteUpdater(
|
||||
appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
quotesStatusesStore: QuotesStatusesStore,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): MultiQuoteUpdater {
|
||||
return DefaultMultiQuoteUpdater(
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStatusesStore = quotesStatusesStore,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,15 @@ package com.tangem.data.quotes.multi
|
|||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.common.api.safeApiCallWithTimeout
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.data.quotes.store.setSourceAsCache
|
||||
import com.tangem.data.quotes.store.setSourceAsOnlyCache
|
||||
import com.tangem.data.quotes.utils.QuotesUnsupportedCurrenciesIdAdapter
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.core.utils.catchOn
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
|
@ -16,34 +18,32 @@ import javax.inject.Inject
|
|||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiQuoteFetcher]
|
||||
* Default implementation of [MultiQuoteStatusFetcher]
|
||||
*
|
||||
* @property tangemTechApi tangemTech api
|
||||
* @property appCurrencyResponseStore app currency response store
|
||||
* @property quotesStore quotes store
|
||||
* @property quotesStatusesStore quotes store
|
||||
* @property dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Singleton
|
||||
internal class DefaultMultiQuoteFetcher @Inject constructor(
|
||||
internal class DefaultMultiQuoteStatusFetcher @Inject constructor(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiQuoteFetcher {
|
||||
) : MultiQuoteStatusFetcher {
|
||||
|
||||
private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter()
|
||||
|
||||
override suspend fun invoke(params: MultiQuoteFetcher.Params) = Either.catchOn(dispatchers.default) {
|
||||
override suspend fun invoke(params: MultiQuoteStatusFetcher.Params) = Either.catchOn(dispatchers.default) {
|
||||
if (params.currenciesIds.isEmpty()) {
|
||||
Timber.d("No currencies to fetch quotes for")
|
||||
return@catchOn
|
||||
}
|
||||
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
quotesStatusesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
|
||||
|
||||
val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies(
|
||||
val replacementIdsResult = QuotesUnsupportedCurrenciesIdAdapter.replaceUnsupportedCurrencies(
|
||||
currenciesIds = params.currenciesIds.mapTo(
|
||||
destination = hashSetOf(),
|
||||
transform = CryptoCurrency.RawID::value,
|
||||
|
|
@ -62,19 +62,19 @@ internal class DefaultMultiQuoteFetcher @Inject constructor(
|
|||
onError = { error -> throw error },
|
||||
)
|
||||
|
||||
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
|
||||
val updatedResponse = QuotesUnsupportedCurrenciesIdAdapter.getResponseWithUnsupportedCurrencies(
|
||||
response = response,
|
||||
filteredIds = replacementIdsResult.idsFiltered,
|
||||
)
|
||||
|
||||
quotesStore.storeActual(values = updatedResponse.quotes)
|
||||
quotesStatusesStore.store(values = updatedResponse.quotes)
|
||||
}
|
||||
.onLeft {
|
||||
Timber.e(it)
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
quotesStatusesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
private suspend fun getAppCurrencyId(params: MultiQuoteFetcher.Params): String {
|
||||
private suspend fun getAppCurrencyId(params: MultiQuoteStatusFetcher.Params): String {
|
||||
val appCurrencyId = params.appCurrencyId
|
||||
?: appCurrencyResponseStore.getSyncOrNull()?.id
|
||||
|
||||
|
|
@ -2,12 +2,12 @@ package com.tangem.data.quotes.multi
|
|||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import arrow.core.left
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.core.utils.EitherFlow
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
|
|
@ -17,24 +17,21 @@ import kotlinx.coroutines.SupervisorJob
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiQuoteUpdater] which updates quotes when the app currency changes
|
||||
*
|
||||
* @property appCurrencyResponseStore app currency response store
|
||||
* @property quotesStore quotes store
|
||||
* @property multiQuoteFetcher multi quote fetcher
|
||||
* @property quotesStatusesStore quotes store
|
||||
* @property multiQuoteStatusFetcher multi quote status fetcher
|
||||
* @param dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Singleton
|
||||
internal class DefaultMultiQuoteUpdater @Inject constructor(
|
||||
internal class DefaultMultiQuoteUpdater(
|
||||
private val appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
private val multiQuoteFetcher: MultiQuoteFetcher,
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiQuoteUpdater {
|
||||
|
||||
|
|
@ -60,11 +57,14 @@ internal class DefaultMultiQuoteUpdater @Inject constructor(
|
|||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.mapLatest { appCurrency ->
|
||||
val currenciesIds = quotesStore.getAllSyncOrNull().orEmpty()
|
||||
.mapTo(destination = hashSetOf(), transform = Quote::rawCurrencyId)
|
||||
val currenciesIds = quotesStatusesStore.getAllSyncOrNull().orEmpty()
|
||||
.mapTo(destination = hashSetOf(), transform = QuoteStatus::rawCurrencyId)
|
||||
|
||||
multiQuoteFetcher(
|
||||
params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrency.id),
|
||||
multiQuoteStatusFetcher(
|
||||
params = MultiQuoteStatusFetcher.Params(
|
||||
currenciesIds = currenciesIds,
|
||||
appCurrencyId = appCurrency.id,
|
||||
),
|
||||
)
|
||||
.onLeft(Timber::e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.data.quotes.repository
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Default implementation of [QuotesRepository]
|
||||
*
|
||||
* @property quotesStatusesStore quotes statuses store
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultQuotesRepository(
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
) : QuotesRepository {
|
||||
|
||||
override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set<CryptoCurrency.RawID>): Set<QuoteStatus> {
|
||||
if (currenciesIds.isEmpty()) {
|
||||
Timber.e("currenciesIds are empty")
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
val storedQuotes = quotesStatusesStore.getAllSyncOrNull()
|
||||
?.filter { it.rawCurrencyId in currenciesIds }
|
||||
|
||||
return currenciesIds.mapTo(hashSetOf()) { currencyId ->
|
||||
storedQuotes?.firstOrNull { it.rawCurrencyId == currencyId }
|
||||
?: QuoteStatus(rawCurrencyId = currencyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteFetcher
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultSingleQuoteFetcher @Inject constructor(
|
||||
private val multiQuoteFetcher: MultiQuoteFetcher,
|
||||
) : SingleQuoteFetcher {
|
||||
|
||||
override suspend fun invoke(params: SingleQuoteFetcher.Params) = multiQuoteFetcher.invoke(
|
||||
MultiQuoteFetcher.Params(
|
||||
currenciesIds = setOf(params.rawCurrencyId),
|
||||
appCurrencyId = params.appCurrencyId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
|
||||
/**
|
||||
* Default implementation of [SingleQuoteProducer]
|
||||
*
|
||||
* @property params params
|
||||
* @property quotesStore quotes store
|
||||
*/
|
||||
internal class DefaultSingleQuoteProducer @AssistedInject constructor(
|
||||
@Assisted val params: SingleQuoteProducer.Params,
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : SingleQuoteProducer {
|
||||
|
||||
override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
|
||||
override fun produce(): Flow<Quote> {
|
||||
return quotesStore.get()
|
||||
.mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SingleQuoteProducer.Factory {
|
||||
override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of [SingleQuoteStatusFetcher]
|
||||
*
|
||||
* @property multiQuoteStatusFetcher fetcher of quotes statuses
|
||||
*/
|
||||
internal class DefaultSingleQuoteStatusFetcher @Inject constructor(
|
||||
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
) : SingleQuoteStatusFetcher {
|
||||
|
||||
override suspend fun invoke(params: SingleQuoteStatusFetcher.Params) = multiQuoteStatusFetcher.invoke(
|
||||
MultiQuoteStatusFetcher.Params(
|
||||
currenciesIds = setOf(params.rawCurrencyId),
|
||||
appCurrencyId = params.appCurrencyId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
|
||||
/**
|
||||
* Default implementation of [SingleQuoteStatusProducer]
|
||||
*
|
||||
* @property params params
|
||||
* @property quotesStatusesStore quotes store
|
||||
*/
|
||||
internal class DefaultSingleQuoteStatusProducer @AssistedInject constructor(
|
||||
@Assisted val params: SingleQuoteStatusProducer.Params,
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : SingleQuoteStatusProducer {
|
||||
|
||||
override val fallback: QuoteStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
|
||||
override fun produce(): Flow<QuoteStatus> {
|
||||
return quotesStatusesStore.get()
|
||||
.mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SingleQuoteStatusProducer.Factory {
|
||||
override fun create(params: SingleQuoteStatusProducer.Params): DefaultSingleQuoteStatusProducer
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.data.quotes.converter.QuoteStatusConverter
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.quote.converter.QuoteConverter
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -15,21 +15,22 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
|
||||
|
||||
/**
|
||||
* Default implementation of [QuotesStoreV2]
|
||||
* Default implementation of [QuotesStatusesStore]
|
||||
*
|
||||
* @property runtimeStore runtime store
|
||||
* @property persistenceDataStore persistence store
|
||||
* @param dispatchers dispatchers
|
||||
*/
|
||||
internal class DefaultQuotesStoreV2(
|
||||
private val runtimeStore: RuntimeSharedStore<Set<Quote>>,
|
||||
internal class DefaultQuotesStatusesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<Set<QuoteStatus>>,
|
||||
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : QuotesStoreV2 {
|
||||
) : QuotesStatusesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
|
|
@ -40,62 +41,68 @@ internal class DefaultQuotesStoreV2(
|
|||
if (cachedStatuses.isNullOrEmpty()) return@launch
|
||||
|
||||
runtimeStore.store(
|
||||
value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries),
|
||||
value = QuoteStatusConverter(source = StatusSource.CACHE).convertSet(input = cachedStatuses.entries),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun get(): Flow<Set<Quote>> = runtimeStore.get()
|
||||
override fun get(): Flow<Set<QuoteStatus>> = runtimeStore.get()
|
||||
|
||||
override suspend fun getAllSyncOrNull(): Set<Quote>? = runtimeStore.getSyncOrNull()
|
||||
override suspend fun getAllSyncOrNull(): Set<QuoteStatus>? = runtimeStore.getSyncOrNull()
|
||||
|
||||
override suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE)
|
||||
}
|
||||
|
||||
override suspend fun storeActual(values: Map<String, QuotesResponse.Quote>) {
|
||||
coroutineScope {
|
||||
launch {
|
||||
val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries)
|
||||
storeInRuntimeStore(values = quotes)
|
||||
}
|
||||
launch { storeInPersistenceStore(values = values) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSourceInRuntime(
|
||||
currenciesIds = currenciesIds,
|
||||
ifNotFound = Quote::Empty,
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
override suspend fun updateStatusSource(
|
||||
currencyId: CryptoCurrency.RawID,
|
||||
source: StatusSource,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus?,
|
||||
) {
|
||||
updateStatusSource(
|
||||
currenciesIds = setOf(currencyId),
|
||||
source = source,
|
||||
ifNotFound = ifNotFound,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateStatusSourceInRuntime(
|
||||
override suspend fun updateStatusSource(
|
||||
currenciesIds: Set<CryptoCurrency.RawID>,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> Quote? = { null },
|
||||
source: StatusSource,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus?,
|
||||
) {
|
||||
if (currenciesIds.isEmpty()) {
|
||||
Timber.d("Nothing to update: currencies ids are empty")
|
||||
return
|
||||
}
|
||||
|
||||
runtimeStore.update(default = emptySet()) { stored ->
|
||||
val updatedQuotes = currenciesIds.mapNotNullTo(hashSetOf()) { id ->
|
||||
val quote = stored.firstOrNull { it.rawCurrencyId == id }
|
||||
?: ifNotFound(id)
|
||||
?: return@mapNotNullTo null
|
||||
|
||||
quote.copySealed(source = source)
|
||||
quote.copy(value = quote.value.copySealed(source = source))
|
||||
}
|
||||
|
||||
stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInRuntimeStore(values: Set<Quote>) {
|
||||
runtimeStore.update(default = emptySet()) { saved ->
|
||||
saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId }
|
||||
override suspend fun store(values: CurrencyIdWithQuote) {
|
||||
if (values.isEmpty()) return
|
||||
|
||||
coroutineScope {
|
||||
launch { storeInRuntime(values = values) }
|
||||
launch { storeInPersistence(values = values) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInPersistenceStore(values: Map<String, QuotesResponse.Quote>) {
|
||||
private suspend fun storeInRuntime(values: CurrencyIdWithQuote) {
|
||||
val quotes = QuoteStatusConverter(source = StatusSource.ACTUAL).convertSet(input = values.entries)
|
||||
|
||||
runtimeStore.update(default = emptySet()) { saved ->
|
||||
saved.addOrReplace(items = quotes) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInPersistence(values: CurrencyIdWithQuote) {
|
||||
persistenceDataStore.updateData { storedQuotes -> storedQuotes + values }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Store of [QuoteStatus]'es set */
|
||||
internal interface QuotesStatusesStore {
|
||||
|
||||
/** Get flow of quotes */
|
||||
fun get(): Flow<Set<QuoteStatus>>
|
||||
|
||||
/** Get all quotes synchronously or null */
|
||||
suspend fun getAllSyncOrNull(): Set<QuoteStatus>?
|
||||
|
||||
/**
|
||||
* Update [source] of [QuoteStatus] by [currencyId].
|
||||
* If the status is not found, create a new one by [ifNotFound].
|
||||
*/
|
||||
suspend fun updateStatusSource(
|
||||
currencyId: CryptoCurrency.RawID,
|
||||
source: StatusSource,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null },
|
||||
)
|
||||
|
||||
/**
|
||||
* Update [source] of [QuoteStatus]es by [currenciesIds].
|
||||
* If the status is not found, create a new one by [ifNotFound].
|
||||
*/
|
||||
suspend fun updateStatusSource(
|
||||
currenciesIds: Set<CryptoCurrency.RawID>,
|
||||
source: StatusSource,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null },
|
||||
)
|
||||
|
||||
/**
|
||||
* Store quotes statuses
|
||||
*
|
||||
* @param values map of currency ids and quotes
|
||||
*
|
||||
* See complex methods in `QuotesStatusesStoreExt`.
|
||||
*/
|
||||
suspend fun store(values: Map<String, QuotesResponse.Quote>)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
|
||||
/** Set [StatusSource] of quotes statuses as [StatusSource.CACHE] for [currenciesIds] */
|
||||
internal suspend fun QuotesStatusesStore.setSourceAsCache(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSource(currenciesIds = currenciesIds, source = StatusSource.CACHE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set [StatusSource] of quotes statuses as [StatusSource.ONLY_CACHE] for [currenciesIds].
|
||||
* If the stored status is not found, store a default [QuoteStatus.Empty] status.
|
||||
*/
|
||||
internal suspend fun QuotesStatusesStore.setSourceAsOnlyCache(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSource(
|
||||
currenciesIds = currenciesIds,
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
ifNotFound = ::QuoteStatus,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Store of [Quote]'es set */
|
||||
internal interface QuotesStoreV2 {
|
||||
|
||||
/** Get flow of quotes */
|
||||
fun get(): Flow<Set<Quote>>
|
||||
|
||||
/** Get all quotes synchronously or null */
|
||||
suspend fun getAllSyncOrNull(): Set<Quote>?
|
||||
|
||||
/** Refresh status of [currenciesIds] */
|
||||
suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>)
|
||||
|
||||
/** Store actual map of currency ids and quotes [values] */
|
||||
suspend fun storeActual(values: Map<String, QuotesResponse.Quote>)
|
||||
|
||||
/** Store error for [currenciesIds] */
|
||||
suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>)
|
||||
}
|
||||
|
|
@ -1,23 +1,27 @@
|
|||
package com.tangem.data.tokens.utils
|
||||
package com.tangem.data.quotes.utils
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
|
||||
/**
|
||||
* Adapter to replace unsupported currencies for quotes request if it necessary
|
||||
*/
|
||||
class QuotesUnsupportedCurrenciesIdAdapter {
|
||||
/** Adapter to replace unsupported currencies for quotes request if it necessary */
|
||||
internal object QuotesUnsupportedCurrenciesIdAdapter {
|
||||
|
||||
/**
|
||||
* Replaces unsupported currencies id to it replacements for request
|
||||
*/
|
||||
/** Ethereum coin ID */
|
||||
private val ethCoinId = Blockchain.Ethereum.toCoinId()
|
||||
|
||||
/** Map that contains unsupported currencies and their replacement for request */
|
||||
private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = l2BlockchainsCoinIds.associateWith { ethCoinId }
|
||||
|
||||
/** Replaces unsupported currencies id [currenciesIds] to it replacements for request */
|
||||
fun replaceUnsupportedCurrencies(currenciesIds: Set<String>): ReplacementResult {
|
||||
val idsForRequest = mutableSetOf<String>()
|
||||
val idsFiltered = mutableSetOf<String>()
|
||||
|
||||
currenciesIds.forEach { currencyId ->
|
||||
val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[currencyId]
|
||||
|
||||
if (replacementId != null) {
|
||||
idsFiltered.add(currencyId)
|
||||
idsForRequest.add(replacementId)
|
||||
|
|
@ -25,16 +29,17 @@ class QuotesUnsupportedCurrenciesIdAdapter {
|
|||
idsForRequest.add(currencyId)
|
||||
}
|
||||
}
|
||||
|
||||
return ReplacementResult(idsForRequest = idsForRequest, idsFiltered = idsFiltered)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover previously replaced currencies to response that uses in application
|
||||
*/
|
||||
/** Recover previously replaced currencies [filteredIds] to [response] that uses in application */
|
||||
fun getResponseWithUnsupportedCurrencies(response: QuotesResponse, filteredIds: Set<String>): QuotesResponse {
|
||||
val updatedQuotes = mutableMapOf<String, QuotesResponse.Quote>()
|
||||
|
||||
filteredIds.forEach { filteredId ->
|
||||
val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[filteredId]
|
||||
|
||||
if (replacementId != null) {
|
||||
val quoteReplacement = response.quotes[replacementId]
|
||||
if (quoteReplacement != null) {
|
||||
|
|
@ -42,16 +47,9 @@ class QuotesUnsupportedCurrenciesIdAdapter {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.copy(quotes = updatedQuotes + response.quotes)
|
||||
}
|
||||
|
||||
data class ReplacementResult(val idsForRequest: Set<String>, val idsFiltered: Set<String>)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Map that contains unsupported currencies and their replacement for request
|
||||
*/
|
||||
private val ethCoinId = Blockchain.Ethereum.toCoinId()
|
||||
private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = l2BlockchainsCoinIds.associateWith { ethCoinId }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package com.tangem.data.quotes.converter
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class QuoteStatusConverterTest {
|
||||
|
||||
private val ethQuoteDM = "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun convert(model: ConvertTestModel) {
|
||||
// Act
|
||||
val actual = QuoteStatusConverter(source = model.source).convert(value = model.value)
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
ConvertTestModel(
|
||||
source = StatusSource.CACHE,
|
||||
value = createEntry(value = ethQuoteDM),
|
||||
expected = ethQuoteDM.toDomain(source = StatusSource.CACHE),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
value = createEntry(value = ethQuoteDM),
|
||||
expected = ethQuoteDM.toDomain(source = StatusSource.ONLY_CACHE),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ACTUAL,
|
||||
value = createEntry(value = ethQuoteDM),
|
||||
expected = ethQuoteDM.toDomain(source = StatusSource.ACTUAL),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ACTUAL,
|
||||
value = createEntry(
|
||||
value = "ETH" to QuotesResponse.Quote(
|
||||
price = null,
|
||||
priceChange24h = null,
|
||||
priceChange1w = null,
|
||||
priceChange30d = null,
|
||||
),
|
||||
),
|
||||
expected = QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID("ETH"),
|
||||
value = QuoteStatus.Data(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatRate = BigDecimal.ZERO,
|
||||
priceChange = BigDecimal("0.00"),
|
||||
),
|
||||
),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ACTUAL,
|
||||
value = createEntry(
|
||||
value = "ETH" to QuotesResponse.Quote(
|
||||
price = null,
|
||||
priceChange24h = null,
|
||||
priceChange1w = BigDecimal.ZERO,
|
||||
priceChange30d = BigDecimal.ZERO,
|
||||
),
|
||||
),
|
||||
expected = QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID("ETH"),
|
||||
value = QuoteStatus.Data(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatRate = BigDecimal.ZERO,
|
||||
priceChange = BigDecimal("0.00"),
|
||||
),
|
||||
),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ACTUAL,
|
||||
value = createEntry(
|
||||
value = "ETH" to QuotesResponse.Quote(
|
||||
price = BigDecimal.ONE,
|
||||
priceChange24h = BigDecimal.ONE,
|
||||
priceChange1w = null,
|
||||
priceChange30d = null,
|
||||
),
|
||||
),
|
||||
expected = QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID("ETH"),
|
||||
value = QuoteStatus.Data(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal("0.01"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun createEntry(value: Pair<String, QuotesResponse.Quote>): Map.Entry<String, QuotesResponse.Quote> {
|
||||
return mapOf(value).entries.first()
|
||||
}
|
||||
|
||||
data class ConvertTestModel(
|
||||
val source: StatusSource,
|
||||
val value: Map.Entry<String, QuotesResponse.Quote>,
|
||||
val expected: QuoteStatus,
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ package com.tangem.data.quotes.multi
|
|||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.data.quotes.store.setSourceAsCache
|
||||
import com.tangem.data.quotes.store.setSourceAsOnlyCache
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
|
|
@ -10,7 +12,7 @@ import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
|||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
|
|
@ -23,22 +25,22 @@ import java.math.BigDecimal
|
|||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiQuoteFetcherTest {
|
||||
internal class DefaultMultiQuoteStatusFetcherTest {
|
||||
|
||||
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
|
||||
private val appCurrencyResponseStore = mockk<AppCurrencyResponseStore>(relaxed = true)
|
||||
private val quotesStore = mockk<QuotesStoreV2>(relaxed = true)
|
||||
private val quotesStore = mockk<QuotesStatusesStore>(relaxed = true)
|
||||
|
||||
private val fetcher = DefaultMultiQuoteFetcher(
|
||||
private val fetcher = DefaultMultiQuoteStatusFetcher(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStore = quotesStore,
|
||||
quotesStatusesStore = quotesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fetch quotes successfully`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
|
|
@ -50,17 +52,17 @@ internal class DefaultMultiQuoteFetcherTest {
|
|||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
|
||||
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
quotesStore.store(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
|
|
@ -68,16 +70,16 @@ internal class DefaultMultiQuoteFetcherTest {
|
|||
|
||||
@Test
|
||||
fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null)
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null)
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.refresh(currenciesIds = any())
|
||||
quotesStore.setSourceAsCache(currenciesIds = any())
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(values = any())
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
quotesStore.store(values = any())
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
|
|
@ -86,7 +88,7 @@ internal class DefaultMultiQuoteFetcherTest {
|
|||
@Test
|
||||
fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest {
|
||||
val appCurrencyId = "usd"
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
|
||||
|
||||
val coinIds = "BTC,ETH"
|
||||
coEvery {
|
||||
|
|
@ -96,16 +98,16 @@ internal class DefaultMultiQuoteFetcherTest {
|
|||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
|
||||
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
quotesStore.store(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
|
|
@ -114,19 +116,19 @@ internal class DefaultMultiQuoteFetcherTest {
|
|||
@Test
|
||||
fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest {
|
||||
val appCurrencyId = ""
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(values = any())
|
||||
quotesStore.store(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
|
|
@ -137,7 +139,7 @@ internal class DefaultMultiQuoteFetcherTest {
|
|||
|
||||
@Test
|
||||
fun `fetch quotes failure because api request failed`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
|
|
@ -150,17 +152,17 @@ internal class DefaultMultiQuoteFetcherTest {
|
|||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
|
||||
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeActual(values = any())
|
||||
quotesStore.store(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
|
|
@ -168,21 +170,21 @@ internal class DefaultMultiQuoteFetcherTest {
|
|||
|
||||
@Test
|
||||
fun `fetch quotes failure because app currency not found`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(values = any())
|
||||
quotesStore.store(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
|
|
@ -3,10 +3,10 @@ package com.tangem.data.quotes.multi
|
|||
import arrow.core.right
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
|
@ -16,16 +16,16 @@ import org.junit.Test
|
|||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiQuoteUpdaterTest {
|
||||
internal class DefaultMultiQuoteStatusUpdaterTest {
|
||||
|
||||
private val appCurrencyResponseStore: AppCurrencyResponseStore = mockk()
|
||||
private val quotesStore: QuotesStoreV2 = mockk()
|
||||
private val multiQuoteFetcher: MultiQuoteFetcher = mockk()
|
||||
private val quotesStore: QuotesStatusesStore = mockk()
|
||||
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk()
|
||||
|
||||
private val multiQuoteUpdater = DefaultMultiQuoteUpdater(
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStore = quotesStore,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
quotesStatusesStore = quotesStore,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
|
|
@ -36,8 +36,8 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
|
||||
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
|
||||
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteFetcher(params) } returns Unit.right()
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteStatusFetcher(params) } returns Unit.right()
|
||||
|
||||
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerifyOrder {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(params)
|
||||
multiQuoteStatusFetcher(params)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,8 +59,8 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
|
||||
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
|
||||
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteFetcher(params) } returns Unit.right()
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteStatusFetcher(params) } returns Unit.right()
|
||||
|
||||
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerifyOrder {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(params)
|
||||
multiQuoteStatusFetcher(params)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(any())
|
||||
multiQuoteStatusFetcher(any())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,8 +111,8 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
|
||||
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
|
||||
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteFetcher(params) } returns Unit.right()
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteStatusFetcher(params) } returns Unit.right()
|
||||
|
||||
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(any())
|
||||
multiQuoteStatusFetcher(any())
|
||||
}
|
||||
|
||||
innerFlow.emit(value = true)
|
||||
|
|
@ -136,7 +136,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerifyOrder {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(params)
|
||||
multiQuoteStatusFetcher(params)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.tangem.data.quotes.repository
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultQuotesRepositoryTest {
|
||||
|
||||
private val quotesStatusesStore = mockk<QuotesStatusesStore>()
|
||||
private val repository: QuotesRepository = DefaultQuotesRepository(quotesStatusesStore = quotesStatusesStore)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(quotesStatusesStore)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class GetMultiQuoteSyncOrNull {
|
||||
|
||||
private val btcRawId = CryptoCurrency.RawID(value = "BTC")
|
||||
private val ethRawId = CryptoCurrency.RawID(value = "ETH")
|
||||
|
||||
private val ethQuote = QuoteStatus(
|
||||
rawCurrencyId = ethRawId,
|
||||
value = QuoteStatus.Data(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatRate = BigDecimal.ZERO,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
),
|
||||
)
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun getMultiQuoteSyncOrNull(model: GetMultiQuoteSyncOrNullModel) = runTest {
|
||||
// Arrange
|
||||
coEvery { quotesStatusesStore.getAllSyncOrNull() } returns model.initialStore
|
||||
|
||||
// Act
|
||||
val actual = repository.getMultiQuoteSyncOrNull(currenciesIds = model.currencyIds)
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = null,
|
||||
currencyIds = emptySet(),
|
||||
expected = emptySet(),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = null,
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = emptySet(),
|
||||
currencyIds = emptySet(),
|
||||
expected = emptySet(),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = emptySet(),
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = setOf(ethQuote),
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(ethQuote),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = setOf(QuoteStatus(rawCurrencyId = btcRawId)),
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = setOf(ethQuote, QuoteStatus(rawCurrencyId = btcRawId)),
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(ethQuote),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class GetMultiQuoteSyncOrNullModel(
|
||||
val initialStore: Set<QuoteStatus>?,
|
||||
val currencyIds: Set<CryptoCurrency.RawID>,
|
||||
val expected: Set<QuoteStatus>?,
|
||||
)
|
||||
}
|
||||
|
|
@ -2,8 +2,10 @@ package com.tangem.data.quotes.single
|
|||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteStatusFetcher
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.data.quotes.store.setSourceAsCache
|
||||
import com.tangem.data.quotes.store.setSourceAsOnlyCache
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
|
|
@ -11,34 +13,34 @@ import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
|||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.quotes.single.SingleQuoteFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class DefaultSingleQuoteFetcherTest {
|
||||
internal class DefaultSingleQuoteStatusFetcherTest {
|
||||
|
||||
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
|
||||
private val appCurrencyResponseStore = mockk<AppCurrencyResponseStore>(relaxed = true)
|
||||
private val quotesStore = mockk<QuotesStoreV2>(relaxed = true)
|
||||
private val quotesStore = mockk<QuotesStatusesStore>(relaxed = true)
|
||||
|
||||
private val multiFetcher = DefaultMultiQuoteFetcher(
|
||||
private val multiFetcher = DefaultMultiQuoteStatusFetcher(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStore = quotesStore,
|
||||
quotesStatusesStore = quotesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
private val singleFetcher = DefaultSingleQuoteFetcher(multiFetcher)
|
||||
private val singleFetcher = DefaultSingleQuoteStatusFetcher(multiFetcher)
|
||||
|
||||
@Test
|
||||
fun `fetch single quote successfully`() = runTest {
|
||||
val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null)
|
||||
val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
|
|
@ -50,14 +52,14 @@ internal class DefaultSingleQuoteFetcherTest {
|
|||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(params.rawCurrencyId))
|
||||
quotesStore.setSourceAsCache(currenciesIds = setOf(params.rawCurrencyId))
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
quotesStore.store(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
|
|
@ -66,7 +68,7 @@ internal class DefaultSingleQuoteFetcherTest {
|
|||
@Test
|
||||
fun `fetch single quote successfully if appCurrencyId from params is not null`() = runTest {
|
||||
val appCurrencyId = "usd"
|
||||
val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId)
|
||||
val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId)
|
||||
|
||||
val coinIds = "BTC"
|
||||
coEvery {
|
||||
|
|
@ -76,9 +78,9 @@ internal class DefaultSingleQuoteFetcherTest {
|
|||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.setSourceAsCache(currenciesIds = setOf(currenciesId))
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
quotesStore.store(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
|
|
@ -87,13 +89,13 @@ internal class DefaultSingleQuoteFetcherTest {
|
|||
@Test
|
||||
fun `fetch single quote failure because appCurrencyId from params is blank`() = runTest {
|
||||
val appCurrencyId = ""
|
||||
val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId)
|
||||
val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId)
|
||||
|
||||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.storeError(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.setSourceAsCache(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = setOf(currenciesId))
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
|
|
@ -104,7 +106,7 @@ internal class DefaultSingleQuoteFetcherTest {
|
|||
|
||||
@Test
|
||||
fun `fetch single quote failure because api request failed`() = runTest {
|
||||
val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null)
|
||||
val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
|
|
@ -117,14 +119,14 @@ internal class DefaultSingleQuoteFetcherTest {
|
|||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.setSourceAsCache(currenciesIds = setOf(currenciesId))
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
quotesStore.storeError(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = setOf(currenciesId))
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeActual(values = any())
|
||||
quotesStore.store(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
|
|
@ -132,21 +134,21 @@ internal class DefaultSingleQuoteFetcherTest {
|
|||
|
||||
@Test
|
||||
fun `fetch single quote failure because app currency not found`() = runTest {
|
||||
val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null)
|
||||
val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null
|
||||
|
||||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.setSourceAsCache(currenciesIds = setOf(currenciesId))
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
quotesStore.storeError(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = setOf(currenciesId))
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(values = any())
|
||||
quotesStore.store(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
|
|
@ -2,11 +2,11 @@ package com.tangem.data.quotes.single
|
|||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
|
@ -19,27 +19,27 @@ import java.math.BigDecimal
|
|||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultSingleQuoteProducerTest {
|
||||
internal class DefaultSingleQuoteStatusProducerTest {
|
||||
|
||||
private val params = SingleQuoteProducer.Params(
|
||||
private val params = SingleQuoteStatusProducer.Params(
|
||||
rawCurrencyId = CryptoCurrency.RawID(value = "BTC"),
|
||||
)
|
||||
|
||||
private val quotesStore = mockk<QuotesStoreV2>()
|
||||
private val quotesStore = mockk<QuotesStatusesStore>()
|
||||
|
||||
private val producer = DefaultSingleQuoteProducer(
|
||||
private val producer = DefaultSingleQuoteStatusProducer(
|
||||
params = params,
|
||||
quotesStore = quotesStore,
|
||||
quotesStatusesStore = quotesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test that flow is mapped for network from params`() = runTest {
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
val storeQuote = flowOf(
|
||||
setOf(
|
||||
status,
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
|
||||
@Test
|
||||
fun `test that flow is updated if quote is updated`() = runTest {
|
||||
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
|
||||
val storeQuote = MutableSharedFlow<Set<QuoteStatus>>(replay = 2, extraBufferCapacity = 1)
|
||||
|
||||
every { quotesStore.get() } returns storeQuote
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
verify { quotesStore.get() }
|
||||
|
||||
// first emit
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
storeQuote.emit(value = setOf(status))
|
||||
|
||||
val values1 = getEmittedValues(flow = actual)
|
||||
|
|
@ -75,11 +75,13 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
Truth.assertThat(values1).isEqualTo(listOf(status))
|
||||
|
||||
// second emit
|
||||
val updatedStatus = Quote.Value(
|
||||
val updatedStatus = QuoteStatus(
|
||||
rawCurrencyId = params.rawCurrencyId,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
value = QuoteStatus.Data(
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
)
|
||||
storeQuote.emit(value = setOf(updatedStatus))
|
||||
|
||||
|
|
@ -91,7 +93,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
|
||||
@Test
|
||||
fun `test that flow is filtered the same status`() = runTest {
|
||||
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
|
||||
val storeQuote = MutableSharedFlow<Set<QuoteStatus>>(replay = 2, extraBufferCapacity = 1)
|
||||
|
||||
every { quotesStore.get() } returns storeQuote
|
||||
|
||||
|
|
@ -100,7 +102,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
verify { quotesStore.get() }
|
||||
|
||||
// first emit
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
storeQuote.emit(value = setOf(status))
|
||||
|
||||
val values1 = getEmittedValues(flow = actual)
|
||||
|
|
@ -120,11 +122,13 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
@Test
|
||||
fun `test if flow throws exception`() = runTest {
|
||||
val exception = IllegalStateException()
|
||||
val status = Quote.Value(
|
||||
val status = QuoteStatus(
|
||||
rawCurrencyId = params.rawCurrencyId,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
value = QuoteStatus.Data(
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
)
|
||||
|
||||
val innerFlow = MutableStateFlow(value = false)
|
||||
|
|
@ -146,7 +150,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
val values1 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values1.size).isEqualTo(1)
|
||||
val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val fallbackStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
|
||||
|
||||
innerFlow.emit(value = true)
|
||||
|
|
@ -160,7 +164,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
fun `test if flow doesn't contain network from params`() = runTest {
|
||||
val storeFlow = flowOf(
|
||||
setOf(
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class QuotesStatusesStoreExtTest {
|
||||
|
||||
private var runtimeStore: RuntimeSharedStore<Set<QuoteStatus>> by Delegates.notNull()
|
||||
private var persistenceStore: MockStateDataStore<CurrencyIdWithQuote> by Delegates.notNull()
|
||||
private var store: DefaultQuotesStatusesStore by Delegates.notNull()
|
||||
|
||||
private val btcQuoteDM = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
private val btcQuote = btcQuoteDM.toDomain()
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
runtimeStore = RuntimeSharedStore()
|
||||
persistenceStore = MockStateDataStore(default = emptyMap())
|
||||
|
||||
store = DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class SetSourceAsCache {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun setSourceAsCache(model: SetSourceTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
store.setSourceAsCache(model.currenciesIds)
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
SetSourceTestModel(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = emptySet(),
|
||||
),
|
||||
SetSourceTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)),
|
||||
),
|
||||
SetSourceTestModel(
|
||||
initialRuntime = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class SetSourceAsOnlyCache {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun setSourceAsOnlyCache(model: SetSourceTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
store.setSourceAsOnlyCache(model.currenciesIds)
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
SetSourceTestModel(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(QuoteStatus(btcQuote.rawCurrencyId)),
|
||||
),
|
||||
SetSourceTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)),
|
||||
),
|
||||
SetSourceTestModel(
|
||||
initialRuntime = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class SetSourceTestModel(
|
||||
val initialRuntime: Set<QuoteStatus>?,
|
||||
val currenciesIds: Set<CryptoCurrency.RawID>,
|
||||
val expected: Set<QuoteStatus>?,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,565 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class QuotesStatusesStoreTest {
|
||||
|
||||
private var runtimeStore: RuntimeSharedStore<Set<QuoteStatus>> by Delegates.notNull()
|
||||
private var persistenceStore: MockStateDataStore<CurrencyIdWithQuote> by Delegates.notNull()
|
||||
private var store: DefaultQuotesStatusesStore by Delegates.notNull()
|
||||
|
||||
// region Data models
|
||||
private val btcQuoteDM = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
private val ethQuoteDM = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
private val btcQuote = btcQuoteDM.toDomain()
|
||||
private val ethQuote = ethQuoteDM.toDomain()
|
||||
private val adaEmptyQuote = QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ADA"))
|
||||
// endregion
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
runtimeStore = RuntimeSharedStore()
|
||||
persistenceStore = MockStateDataStore(default = emptyMap())
|
||||
|
||||
store = DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Initialization {
|
||||
|
||||
@Test
|
||||
fun `initialization if cache store is empty`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
|
||||
val persistenceStore: DataStore<CurrencyIdWithQuote> = mockk()
|
||||
|
||||
every { persistenceStore.data } returns emptyFlow()
|
||||
|
||||
// Act
|
||||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = null
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialization if cache store contains empty map`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
|
||||
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
// Act
|
||||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = null
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialization if cache store is not empty`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
|
||||
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
persistenceStore.updateData {
|
||||
it.toMutableMap().apply {
|
||||
this += btcQuoteDM
|
||||
this += ethQuoteDM
|
||||
}
|
||||
}
|
||||
|
||||
// Act
|
||||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = setOf(
|
||||
btcQuoteDM.toDomain(source = StatusSource.CACHE),
|
||||
ethQuoteDM.toDomain(source = StatusSource.CACHE),
|
||||
)
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Get {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun get(model: GetTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
val actual = getEmittedValues(flow = store.get())
|
||||
|
||||
// Assert
|
||||
val expected = listOfNotNull(model.expected)
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
GetTestModel(initialRuntime = null, expected = null),
|
||||
GetTestModel(initialRuntime = emptySet(), expected = emptySet()),
|
||||
GetTestModel(initialRuntime = setOf(btcQuote, ethQuote), expected = setOf(btcQuote, ethQuote)),
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class GetAllSyncOrNull {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun getAllSyncOrNull(model: GetTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
GetTestModel(initialRuntime = null, expected = null),
|
||||
GetTestModel(initialRuntime = emptySet(), expected = emptySet()),
|
||||
GetTestModel(initialRuntime = setOf(btcQuote, ethQuote), expected = setOf(btcQuote, ethQuote)),
|
||||
)
|
||||
}
|
||||
|
||||
data class GetTestModel(val initialRuntime: Set<QuoteStatus>?, val expected: Set<QuoteStatus>?)
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class SingleUpdateStatusSource {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun updateStatusSource(model: UpdateStatusSourceModel.Single) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
store.updateStatusSource(
|
||||
currencyId = model.currencyId,
|
||||
source = model.source,
|
||||
ifNotFound = model.ifNotFound,
|
||||
)
|
||||
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// region runtime store is null
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = null,
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = null,
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(
|
||||
QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId),
|
||||
),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store is empty
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = emptySet(),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = emptySet(),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(
|
||||
QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId),
|
||||
),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store contains statuses
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = setOf(btcQuote, ethQuote, adaEmptyQuote),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.CACHE,
|
||||
expected = setOf(
|
||||
btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.CACHE)),
|
||||
ethQuote,
|
||||
adaEmptyQuote,
|
||||
),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = setOf(btcQuote, ethQuote),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
expected = setOf(
|
||||
btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.ONLY_CACHE)),
|
||||
ethQuote,
|
||||
),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = setOf(btcQuote, ethQuote),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.ACTUAL,
|
||||
expected = setOf(btcQuote, ethQuote),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currencyId = adaEmptyQuote.rawCurrencyId,
|
||||
source = StatusSource.ACTUAL,
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(btcQuote, adaEmptyQuote),
|
||||
),
|
||||
// endregion
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class MultiUpdateStatusSource {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun updateStatusSource(model: UpdateStatusSourceModel.Multi) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
store.updateStatusSource(
|
||||
currenciesIds = model.currenciesIds,
|
||||
source = model.source,
|
||||
ifNotFound = model.ifNotFound,
|
||||
)
|
||||
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// region runtime store is null
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(),
|
||||
source = StatusSource.CACHE, // never-mind
|
||||
expected = null,
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(
|
||||
btcQuote.rawCurrencyId,
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
),
|
||||
source = StatusSource.ACTUAL, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(
|
||||
QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId),
|
||||
),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store is empty
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = emptySet(),
|
||||
currenciesIds = setOf(),
|
||||
source = StatusSource.CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = emptySet(),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = emptySet(),
|
||||
currenciesIds = setOf(
|
||||
btcQuote.rawCurrencyId,
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
),
|
||||
source = StatusSource.ACTUAL, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = emptySet(),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(
|
||||
QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId),
|
||||
),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store contains statuses
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote, adaEmptyQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.CACHE,
|
||||
expected = setOf(
|
||||
btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.CACHE)),
|
||||
adaEmptyQuote,
|
||||
),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote, adaEmptyQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
expected = setOf(
|
||||
btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.ONLY_CACHE)),
|
||||
adaEmptyQuote,
|
||||
),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote, adaEmptyQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.ACTUAL,
|
||||
expected = setOf(btcQuote, adaEmptyQuote),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.ACTUAL,
|
||||
expected = setOf(btcQuote),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.ACTUAL,
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(btcQuote, adaEmptyQuote),
|
||||
),
|
||||
// endregion
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface UpdateStatusSourceModel {
|
||||
|
||||
val initialRuntime: Set<QuoteStatus>?
|
||||
val source: StatusSource
|
||||
val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus?
|
||||
val expected: Set<QuoteStatus>?
|
||||
|
||||
data class Single(
|
||||
override val initialRuntime: Set<QuoteStatus>?,
|
||||
val currencyId: CryptoCurrency.RawID,
|
||||
override val source: StatusSource,
|
||||
override val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null },
|
||||
override val expected: Set<QuoteStatus>?,
|
||||
) : UpdateStatusSourceModel
|
||||
|
||||
data class Multi(
|
||||
override val initialRuntime: Set<QuoteStatus>?,
|
||||
val currenciesIds: Set<CryptoCurrency.RawID>,
|
||||
override val source: StatusSource,
|
||||
override val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null },
|
||||
override val expected: Set<QuoteStatus>?,
|
||||
) : UpdateStatusSourceModel
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Store {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun store(model: StoreTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
if (model.initialPersistence != null) {
|
||||
persistenceStore.updateData { model.initialPersistence }
|
||||
}
|
||||
|
||||
// Act
|
||||
store.store(values = model.values)
|
||||
|
||||
val runtimeActual = runtimeStore.getSyncOrNull()
|
||||
val persistenceActual = getEmittedValues(persistenceStore.data)
|
||||
|
||||
// Assert
|
||||
val runtimeExpected = model.runtimeExpected
|
||||
val persistenceExpected = listOf(model.persistenceExpected)
|
||||
|
||||
Truth.assertThat(runtimeActual).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceActual).isEqualTo(persistenceExpected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// region stores are null
|
||||
StoreTestModel(
|
||||
initialRuntime = null,
|
||||
initialPersistence = null,
|
||||
values = emptyMap(),
|
||||
runtimeExpected = null,
|
||||
persistenceExpected = emptyMap(),
|
||||
),
|
||||
StoreTestModel(
|
||||
initialRuntime = null,
|
||||
initialPersistence = null,
|
||||
values = mapOf(btcQuoteDM),
|
||||
runtimeExpected = setOf(btcQuote),
|
||||
persistenceExpected = mapOf(btcQuoteDM),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region persistence store is null
|
||||
StoreTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
initialPersistence = null,
|
||||
values = emptyMap(),
|
||||
runtimeExpected = setOf(btcQuote),
|
||||
persistenceExpected = emptyMap(),
|
||||
),
|
||||
StoreTestModel(
|
||||
initialRuntime = setOf(ethQuote),
|
||||
initialPersistence = null,
|
||||
values = mapOf(btcQuoteDM),
|
||||
runtimeExpected = setOf(ethQuote, btcQuote),
|
||||
persistenceExpected = mapOf(btcQuoteDM),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store is null
|
||||
StoreTestModel(
|
||||
initialRuntime = null,
|
||||
initialPersistence = mapOf(btcQuoteDM),
|
||||
values = emptyMap(),
|
||||
runtimeExpected = null,
|
||||
persistenceExpected = mapOf(btcQuoteDM),
|
||||
),
|
||||
StoreTestModel(
|
||||
initialRuntime = null,
|
||||
initialPersistence = mapOf(ethQuoteDM),
|
||||
values = mapOf(btcQuoteDM),
|
||||
runtimeExpected = setOf(btcQuote),
|
||||
persistenceExpected = mapOf(ethQuoteDM, btcQuoteDM),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region stores contain data
|
||||
StoreTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
initialPersistence = mapOf(btcQuoteDM),
|
||||
values = emptyMap(),
|
||||
runtimeExpected = setOf(btcQuote),
|
||||
persistenceExpected = mapOf(btcQuoteDM),
|
||||
),
|
||||
StoreTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
initialPersistence = mapOf(btcQuoteDM),
|
||||
values = mapOf(ethQuoteDM),
|
||||
runtimeExpected = setOf(ethQuote, btcQuote),
|
||||
persistenceExpected = mapOf(ethQuoteDM, btcQuoteDM),
|
||||
),
|
||||
// endregion
|
||||
)
|
||||
}
|
||||
|
||||
data class StoreTestModel(
|
||||
val initialRuntime: Set<QuoteStatus>?,
|
||||
val initialPersistence: CurrencyIdWithQuote?,
|
||||
val values: CurrencyIdWithQuote,
|
||||
val persistenceExpected: CurrencyIdWithQuote?,
|
||||
val runtimeExpected: Set<QuoteStatus>?,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuotesStoreGetMethodTest {
|
||||
|
||||
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
private val store = DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test get if runtime store is empty`() = runTest {
|
||||
val actual = store.get()
|
||||
|
||||
val values = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values).isEqualTo(emptyList<Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test get if runtime store contains empty set`() = runTest {
|
||||
runtimeStore.store(value = emptySet())
|
||||
|
||||
val actual = store.get()
|
||||
|
||||
val values = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values).isEqualTo(listOf(emptySet<Quote>()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test get if runtime store is not empty`() = runTest {
|
||||
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
|
||||
|
||||
val actual = store.get()
|
||||
|
||||
val values = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values.size).isEqualTo(1)
|
||||
Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getAllSyncOrNull if runtime store is empty`() = runTest {
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getAllSyncOrNull if runtime store contains empty set`() = runTest {
|
||||
runtimeStore.store(value = emptySet())
|
||||
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(emptySet<Quote>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getAllSyncOrNull if runtime store is not empty`() = runTest {
|
||||
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
|
||||
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
val expected = setOf(btcQuote.toDomain(), ethQuote.toDomain())
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuotesStoreInitializationTest {
|
||||
|
||||
@Test
|
||||
fun `test initialization if cache store is empty`() = runTest {
|
||||
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
val persistenceStore: DataStore<CurrencyIdWithQuote> = mockk()
|
||||
|
||||
every { persistenceStore.data } returns emptyFlow()
|
||||
|
||||
DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test initialization if cache store contains empty map`() = runTest {
|
||||
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test initialization if cache store is not empty`() = runTest {
|
||||
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
|
||||
|
||||
persistenceStore.updateData {
|
||||
it.toMutableMap().apply {
|
||||
this += btcQuote
|
||||
this += ethQuote
|
||||
}
|
||||
}
|
||||
|
||||
DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
val expected = setOf(
|
||||
btcQuote.toDomain(source = StatusSource.CACHE),
|
||||
ethQuote.toDomain(source = StatusSource.CACHE),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.data.quote.toDomain
|
||||
import com.tangem.common.test.datastore.MockStateDataStore
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuotesStoreUpdateMethodsTest {
|
||||
|
||||
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
|
||||
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
private val store = DefaultQuotesStoreV2(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `refresh if runtime store is empty`() = runTest {
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
store.refresh(currenciesIds = currenciesIds)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptySet<Quote>())
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh if runtime store contains quote with this id`() = runTest {
|
||||
val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL)
|
||||
|
||||
runtimeStore.store(value = setOf(quote))
|
||||
|
||||
store.refresh(currenciesIds = setOf(quote.rawCurrencyId))
|
||||
|
||||
val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE))
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store actual if runtime and cache stores contain quotes with this id`() = runTest {
|
||||
val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(
|
||||
value = setOf(
|
||||
prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
|
||||
),
|
||||
)
|
||||
|
||||
persistenceStore.updateData {
|
||||
it.toMutableMap().apply {
|
||||
put("BTC", prevStatus)
|
||||
}
|
||||
}
|
||||
|
||||
val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN)
|
||||
|
||||
store.storeActual(values = mapOf("BTC" to newStatus))
|
||||
|
||||
val runtimeExpected = setOf(
|
||||
newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL),
|
||||
)
|
||||
val persistenceExpected = mapOf("BTC" to newStatus)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store error if runtime store is empty`() = runTest {
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
store.storeError(currenciesIds = currenciesIds)
|
||||
|
||||
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `store error if runtime store contains status with this network`() = runTest {
|
||||
val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
|
||||
runtimeStore.store(
|
||||
value = setOf(
|
||||
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE),
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
store.storeError(
|
||||
currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
),
|
||||
)
|
||||
|
||||
val runtimeExpected = setOf(
|
||||
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.tangem.data.quotes.utils
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
import com.tangem.data.quotes.utils.QuotesUnsupportedCurrenciesIdAdapter.ReplacementResult
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse.Quote
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class QuotesUnsupportedCurrenciesIdAdapterTest {
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class ReplaceUnsupportedCurrencies {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun replaceUnsupportedCurrencies(model: ReplaceUnsupportedCurrenciesModel) {
|
||||
// Act
|
||||
val actual = QuotesUnsupportedCurrenciesIdAdapter.replaceUnsupportedCurrencies(
|
||||
currenciesIds = model.currenciesIds,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
ReplaceUnsupportedCurrenciesModel(
|
||||
currenciesIds = l2BlockchainsCoinIds.toSet(),
|
||||
expected = ReplacementResult(
|
||||
idsForRequest = l2BlockchainsCoinIds.mapTo(hashSetOf()) {
|
||||
Blockchain.Ethereum.toCoinId()
|
||||
},
|
||||
idsFiltered = l2BlockchainsCoinIds.toSet(),
|
||||
),
|
||||
),
|
||||
ReplaceUnsupportedCurrenciesModel(
|
||||
currenciesIds = notL2BlockchainsCoinIds,
|
||||
expected = ReplacementResult(idsForRequest = notL2BlockchainsCoinIds, idsFiltered = emptySet()),
|
||||
),
|
||||
ReplaceUnsupportedCurrenciesModel(
|
||||
currenciesIds = blockchainCoinIds,
|
||||
expected = ReplacementResult(
|
||||
idsForRequest = Blockchain.entries
|
||||
.filterNot(Blockchain::isTestnet)
|
||||
.mapTo(hashSetOf()) {
|
||||
if (it.isL2EthereumNetwork()) {
|
||||
Blockchain.Ethereum.toCoinId()
|
||||
} else {
|
||||
it.toCoinId()
|
||||
}
|
||||
},
|
||||
idsFiltered = l2BlockchainsCoinIds.toSet(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class ReplaceUnsupportedCurrenciesModel(
|
||||
val currenciesIds: Set<String>,
|
||||
val expected: ReplacementResult,
|
||||
)
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class GetResponseWithUnsupportedCurrencies {
|
||||
|
||||
private val zeroQuote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ZERO)
|
||||
private val ethQuote = "ethereum" to zeroQuote
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun getResponseWithUnsupportedCurrencies(model: GetResponseWithUnsupportedCurrenciesModel) {
|
||||
// Act
|
||||
val actual = QuotesUnsupportedCurrenciesIdAdapter.getResponseWithUnsupportedCurrencies(
|
||||
response = model.response,
|
||||
filteredIds = model.filteredIds,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
GetResponseWithUnsupportedCurrenciesModel(
|
||||
response = QuotesResponse(quotes = emptyMap()),
|
||||
filteredIds = emptySet(),
|
||||
expected = QuotesResponse(quotes = emptyMap()),
|
||||
),
|
||||
GetResponseWithUnsupportedCurrenciesModel(
|
||||
response = createQuotesResponse(ethQuote),
|
||||
filteredIds = emptySet(),
|
||||
expected = createQuotesResponse(ethQuote),
|
||||
),
|
||||
GetResponseWithUnsupportedCurrenciesModel(
|
||||
response = createQuotesResponse(
|
||||
"arbitrum-one" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
|
||||
ethQuote,
|
||||
),
|
||||
filteredIds = blockchainCoinIds,
|
||||
expected = createQuotesResponse(
|
||||
*l2BlockchainsCoinIds.map { it to zeroQuote }.toTypedArray(),
|
||||
"arbitrum-one" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
|
||||
ethQuote,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun createQuotesResponse(vararg quotes: Pair<String, Quote>): QuotesResponse {
|
||||
return QuotesResponse(quotes = quotes.toMap())
|
||||
}
|
||||
}
|
||||
|
||||
data class GetResponseWithUnsupportedCurrenciesModel(
|
||||
val response: QuotesResponse,
|
||||
val filteredIds: Set<String>,
|
||||
val expected: QuotesResponse,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
val blockchainCoinIds = Blockchain.entries
|
||||
.filterNot(Blockchain::isTestnet)
|
||||
.mapTo(destination = hashSetOf(), transform = Blockchain::toCoinId)
|
||||
|
||||
val notL2BlockchainsCoinIds = Blockchain.entries
|
||||
.filterNot { it.isTestnet() || it.isL2EthereumNetwork() }
|
||||
.mapTo(hashSetOf()) { it.toCoinId() }
|
||||
}
|
||||
}
|
||||
54
data/swap/build.gradle.kts
Normal file
54
data/swap/build.gradle.kts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.swap"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.express)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.express)
|
||||
implementation(projects.domain.swap.models)
|
||||
implementation(projects.domain.swap)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Tangem SDK */
|
||||
implementation(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
|
||||
/** Libs */
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.timber)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.data.swap
|
||||
|
||||
import com.tangem.data.express.converter.ExpressErrorConverter
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.swap.SwapErrorResolver
|
||||
|
||||
internal class DefaultSwapErrorResolver(
|
||||
private val expressErrorConverter: ExpressErrorConverter,
|
||||
) : SwapErrorResolver {
|
||||
override fun resolve(throwable: Throwable): ExpressError {
|
||||
return when (throwable) {
|
||||
is ApiResponseError.HttpException -> {
|
||||
expressErrorConverter.convert(throwable.errorBody.orEmpty())
|
||||
}
|
||||
else -> ExpressError.UnknownError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
package com.tangem.data.swap
|
||||
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.swap.converter.TokenInfoConverter
|
||||
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.exchangeservice.swap.ExpressUtils
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.express.ExpressRepository
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.SwapRepositoryV2
|
||||
import com.tangem.domain.swap.models.SwapPairModel
|
||||
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
|
||||
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultSwapRepositoryV2 @Inject constructor(
|
||||
private val tangemExpressApi: TangemExpressApi,
|
||||
private val expressRepository: ExpressRepository,
|
||||
private val coroutineDispatcher: CoroutineDispatcherProvider,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val currencyStatusOperations: BaseCurrencyStatusOperations,
|
||||
) : SwapRepositoryV2 {
|
||||
|
||||
private val tokenInfoConverter = TokenInfoConverter()
|
||||
override suspend fun getPairs(
|
||||
userWallet: UserWallet,
|
||||
initialCurrency: CryptoCurrency,
|
||||
cryptoCurrencyStatusList: List<CryptoCurrencyStatus>,
|
||||
): List<SwapPairModel> = withContext(coroutineDispatcher.io) {
|
||||
val cryptoCurrencyList = cryptoCurrencyStatusList.map { it.currency }
|
||||
|
||||
val allPairs = getPairsInternal(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
)
|
||||
|
||||
val providers = expressRepository.getProviders(userWallet = userWallet)
|
||||
val mappedProviders = providers.associateBy(ExpressProvider::providerId)
|
||||
|
||||
allPairs.map { pair ->
|
||||
async {
|
||||
val statusFrom = cryptoCurrencyStatusList
|
||||
.firstOrNull {
|
||||
it.currency.getContractAddress() == pair.from.contractAddress &&
|
||||
it.currency.network.backendId == pair.from.network
|
||||
}
|
||||
val statusTo = cryptoCurrencyStatusList
|
||||
.firstOrNull {
|
||||
it.currency.getContractAddress() == pair.to.contractAddress &&
|
||||
it.currency.network.backendId == pair.to.network
|
||||
}
|
||||
|
||||
if (statusFrom != null && statusTo != null) {
|
||||
SwapPairModel(
|
||||
from = statusFrom,
|
||||
to = statusTo,
|
||||
providers = pair.providers.mapNotNull {
|
||||
mappedProviders[it.providerId]
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
override suspend fun getPairsOnly(
|
||||
userWallet: UserWallet,
|
||||
initialCurrency: CryptoCurrency,
|
||||
cryptoCurrencyList: List<CryptoCurrency>,
|
||||
): List<SwapPairModel> = withContext(coroutineDispatcher.io) {
|
||||
val allPairs = getPairsInternal(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
)
|
||||
|
||||
allPairs.map { pair ->
|
||||
async {
|
||||
val statusFromDeferred = async {
|
||||
cryptoCurrencyList
|
||||
.firstOrNull {
|
||||
it.getContractAddress() == pair.from.contractAddress &&
|
||||
it.network.backendId == pair.from.network
|
||||
}
|
||||
}
|
||||
val statusToDeferred = async {
|
||||
cryptoCurrencyList
|
||||
.firstOrNull {
|
||||
it.getContractAddress() == pair.to.contractAddress &&
|
||||
it.network.backendId == pair.to.network
|
||||
}
|
||||
}
|
||||
|
||||
createPairModelOnly(
|
||||
currencyFrom = statusFromDeferred.await(),
|
||||
currencyTo = statusToDeferred.await(),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
private suspend fun CoroutineScope.getPairsInternal(
|
||||
userWallet: UserWallet,
|
||||
initialCurrency: CryptoCurrency,
|
||||
cryptoCurrencyList: List<CryptoCurrency>,
|
||||
) = awaitAll(
|
||||
// original pairs
|
||||
async {
|
||||
invokePairRequest(
|
||||
userWallet = userWallet,
|
||||
from = arrayListOf(initialCurrency),
|
||||
to = cryptoCurrencyList,
|
||||
)
|
||||
},
|
||||
// reversed pairs
|
||||
async {
|
||||
invokePairRequest(
|
||||
userWallet = userWallet,
|
||||
from = cryptoCurrencyList,
|
||||
to = arrayListOf(initialCurrency),
|
||||
)
|
||||
},
|
||||
).flatten()
|
||||
|
||||
private suspend fun invokePairRequest(
|
||||
userWallet: UserWallet,
|
||||
from: List<CryptoCurrency>,
|
||||
to: List<CryptoCurrency>,
|
||||
) = safeApiCall(
|
||||
call = {
|
||||
tangemExpressApi.getPairs(
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
),
|
||||
body = PairsRequestBody(
|
||||
from = tokenInfoConverter.convertList(from),
|
||||
to = tokenInfoConverter.convertList(to),
|
||||
),
|
||||
).getOrThrow()
|
||||
},
|
||||
onError = {
|
||||
Timber.w(it, "Unable to get pairs")
|
||||
throw it
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun CoroutineScope.createPairModelOnly(
|
||||
currencyFrom: CryptoCurrency?,
|
||||
currencyTo: CryptoCurrency?,
|
||||
userWalletId: UserWalletId,
|
||||
): SwapPairModel? {
|
||||
return if (currencyFrom != null && currencyTo != null) {
|
||||
val statusFrom = currencyStatusOperations.getCurrencyStatusSync(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyId = currencyFrom.id,
|
||||
).getOrNull()
|
||||
val statusTo = currencyStatusOperations.getCurrencyStatusSync(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyId = currencyTo.id,
|
||||
).getOrNull()
|
||||
|
||||
if (statusFrom != null && statusTo != null) {
|
||||
SwapPairModel(
|
||||
from = statusFrom,
|
||||
to = statusTo,
|
||||
providers = emptyList(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun CryptoCurrency.getContractAddress(): String {
|
||||
return when (this) {
|
||||
is CryptoCurrency.Token -> this.contractAddress
|
||||
is CryptoCurrency.Coin -> "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.data.swap.converter
|
||||
|
||||
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.models.TokenInfo
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
class TokenInfoConverter : Converter<CryptoCurrency, LeastTokenInfo> {
|
||||
|
||||
override fun convert(value: CryptoCurrency): LeastTokenInfo {
|
||||
return LeastTokenInfo(
|
||||
contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0",
|
||||
network = value.network.backendId,
|
||||
)
|
||||
}
|
||||
|
||||
fun convert(value: TokenInfo): LeastTokenInfo {
|
||||
return LeastTokenInfo(
|
||||
contractAddress = value.contractAddress,
|
||||
network = value.network,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.tangem.data.swap.di
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.express.converter.ExpressErrorConverter
|
||||
import com.tangem.data.swap.DefaultSwapErrorResolver
|
||||
import com.tangem.data.swap.DefaultSwapRepositoryV2
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.express.ExpressRepository
|
||||
import com.tangem.domain.swap.SwapErrorResolver
|
||||
import com.tangem.domain.swap.SwapRepositoryV2
|
||||
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
internal object SwapDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSwapErrorResolver(@NetworkMoshi moshi: Moshi): SwapErrorResolver {
|
||||
val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java)
|
||||
return DefaultSwapErrorResolver(
|
||||
ExpressErrorConverter(jsonAdapter),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSwapRepository(
|
||||
tangemExpressApi: TangemExpressApi,
|
||||
expressRepository: ExpressRepository,
|
||||
coroutineDispatcher: CoroutineDispatcherProvider,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
currencyStatusOperations: BaseCurrencyStatusOperations,
|
||||
): SwapRepositoryV2 {
|
||||
return DefaultSwapRepositoryV2(
|
||||
tangemExpressApi = tangemExpressApi,
|
||||
expressRepository = expressRepository,
|
||||
coroutineDispatcher = coroutineDispatcher,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
currencyStatusOperations = currencyStatusOperations,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -47,12 +47,14 @@ internal class DefaultVisaAuthRepository @Inject constructor(
|
|||
}
|
||||
|
||||
override suspend fun getCardWalletAuthChallenge(
|
||||
cardId: String,
|
||||
cardWalletAddress: String,
|
||||
): Either<VisaApiError, VisaAuthChallenge.Wallet> = withContext(dispatchers.io) {
|
||||
request {
|
||||
visaAuthApi.generateNonceByCardWallet(
|
||||
GenerateNoneByCardWalletRequest(
|
||||
cardWalletAddress = cardWalletAddress,
|
||||
cardId = cardId,
|
||||
),
|
||||
).getOrThrow()
|
||||
}.map { response ->
|
||||
|
|
@ -82,6 +84,7 @@ internal class DefaultVisaAuthRepository @Inject constructor(
|
|||
GetAccessTokenByCardWalletRequest(
|
||||
sessionId = signedChallenge.challenge.session.sessionId,
|
||||
signature = signedChallenge.signature,
|
||||
salt = signedChallenge.salt,
|
||||
),
|
||||
).getOrThrow()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor(
|
|||
emitAll(delegate.invoke(transactionData))
|
||||
}
|
||||
|
||||
override suspend fun getAmount(): Amount? {
|
||||
override fun getAmount(): Amount? {
|
||||
return approvalAmount
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ internal class WcEthSignTransactionUseCase @AssistedInject constructor(
|
|||
return dAppFee
|
||||
}
|
||||
|
||||
override suspend fun getAmount(): Amount? {
|
||||
override fun getAmount(): Amount? {
|
||||
return approvalAmount
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ internal object WcEthTxHelper {
|
|||
.hexToBigDecimal()
|
||||
.movePointLeft(blockchain.decimals())
|
||||
|
||||
val callData = CompiledSmartContractCallData(txParams.data.removePrefix(HEX_PREFIX).hexToBytes())
|
||||
val callData = txParams.data?.removePrefix(HEX_PREFIX)?.hexToBytes()?.let {
|
||||
CompiledSmartContractCallData(it)
|
||||
}
|
||||
return TransactionData.Uncompiled(
|
||||
amount = Amount(value, blockchain),
|
||||
fee = dAppFee,
|
||||
|
|
@ -62,10 +64,10 @@ internal object WcEthTxHelper {
|
|||
)
|
||||
}
|
||||
|
||||
fun getApprovedAmount(txData: String, result: CheckTransactionResult): ApprovedAmount? {
|
||||
fun getApprovedAmount(txData: String?, result: CheckTransactionResult): ApprovedAmount? {
|
||||
val approvalMethodId = ApprovalERC20TokenCallData("", null).methodId
|
||||
val isApprovalWcMethod = txData.startsWith(approvalMethodId)
|
||||
if (!isApprovalWcMethod) return null
|
||||
val isApprovalWcMethod = txData?.startsWith(approvalMethodId)
|
||||
if (isApprovalWcMethod != true) return null
|
||||
val simulation = result.simulation as? SimulationResult.Success
|
||||
?: return null
|
||||
val approves = (simulation.data as? SimulationData.Approve)?.approvedAmounts
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue