Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-20 16:43:52 +03:00
commit 7047ce9377
270 changed files with 5769 additions and 1976 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="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>

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

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

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="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>

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

View file

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

View file

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

View file

@ -39,6 +39,7 @@ internal object BlockAidMapper {
} else {
mapSimulationSuccessResult(from.simulation.accountSummary)
},
description = from.validation.description,
)
}

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -119,7 +119,7 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor(
emitAll(delegate.invoke(transactionData))
}
override suspend fun getAmount(): Amount? {
override fun getAmount(): Amount? {
return approvalAmount
}

View file

@ -127,7 +127,7 @@ internal class WcEthSignTransactionUseCase @AssistedInject constructor(
return dAppFee
}
override suspend fun getAmount(): Amount? {
override fun getAmount(): Amount? {
return approvalAmount
}

View file

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