diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index d66c81c65f..d4488fe919 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -2,10 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.tokens.* -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository -import com.tangem.domain.tokens.repository.NetworksRepository -import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -231,4 +228,10 @@ internal object TokensDomainModule { ): GetMissedAddressesCryptoCurrenciesUseCase { return GetMissedAddressesCryptoCurrenciesUseCase(currenciesRepository = currenciesRepository) } + + @Provides + @ViewModelScoped + fun provideGetGlobalTokenListUseCase(tokensListRepository: TokensListRepository): GetGlobalTokenListUseCase { + return GetGlobalTokenListUseCase(repository = tokensListRepository) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt index cf0b07bbfe..277d3f4a52 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt @@ -32,6 +32,16 @@ fun CryptoCurrency.Token.tryGetBackgroundForTokenIcon( ): Color { if (isGrayscale) return TangemColorPalette.Dark2 + return tryGetBackgroundForTokenIcon(contractAddress = contractAddress, fallbackColor = fallbackColor) +} + +/** + * Tries to extract a background color from the contract address of a token. + * + * @param fallbackColor The color to use as a fallback. + * @return The extracted background color or the fallback color if extraction fails or if it is a test network token. + */ +fun tryGetBackgroundForTokenIcon(contractAddress: String, fallbackColor: Color = TangemColorPalette.Black): Color { return try { val colorHex = "#" + contractAddress.substring(range = COLOR_HEX_START_INDEX..COLOR_HEX_END_INDEX) Color(colorHex.toColorInt()) diff --git a/data/tokens/build.gradle.kts b/data/tokens/build.gradle.kts index 88d7471830..f183caa14f 100644 --- a/data/tokens/build.gradle.kts +++ b/data/tokens/build.gradle.kts @@ -45,4 +45,5 @@ dependencies { implementation(deps.jodatime) implementation(deps.timber) implementation(deps.retrofit) // For HttpException + implementation(deps.androidx.paging.runtime) } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 80ceefbdc9..f435378502 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -1,10 +1,7 @@ package com.tangem.data.tokens.di import com.tangem.data.common.cache.CacheRegistry -import com.tangem.data.tokens.repository.DefaultCurrenciesRepository -import com.tangem.data.tokens.repository.DefaultMarketCryptoCurrencyRepository -import com.tangem.data.tokens.repository.DefaultNetworksRepository -import com.tangem.data.tokens.repository.DefaultQuotesRepository +import com.tangem.data.tokens.repository.* import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.network.NetworksStatusesStore import com.tangem.datasource.local.preferences.AppPreferencesStore @@ -12,10 +9,7 @@ import com.tangem.datasource.local.quote.QuotesStore import com.tangem.datasource.local.token.UserMarketCoinsStore import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository -import com.tangem.domain.tokens.repository.NetworksRepository -import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -93,4 +87,18 @@ internal object TokensDataModule { ): MarketCryptoCurrencyRepository { return DefaultMarketCryptoCurrencyRepository(userMarketCoinsStore) } + + @Provides + @Singleton + fun providesTokensListRepository( + tangemTechApi: TangemTechApi, + dispatchers: CoroutineDispatcherProvider, + quotesRepository: QuotesRepository, + ): TokensListRepository { + return DefaultTokensListRepository( + tangemTechApi = tangemTechApi, + dispatchers = dispatchers, + quotesRepository = quotesRepository, + ) + } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt new file mode 100644 index 0000000000..beb9f10b36 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt @@ -0,0 +1,77 @@ +package com.tangem.data.tokens.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.CoinsResponse +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.tokens.model.Token +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * A PagingSource responsible for retrieving tokens from the Tangem Tech API and adding to them quotes information. + * + * @property api Tangem Tech API + * @property quotesRepository Repository providing quotes data. + * @property dispatchers Coroutine dispatchers provider. + * @property searchText The search text used to filter tokens. + */ +internal class CoinsPagingSource( + private val api: TangemTechApi, + private val quotesRepository: QuotesRepository, + private val dispatchers: CoroutineDispatcherProvider, + private val searchText: String?, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(other = 1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(other = 1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 0 + + return com.tangem.utils.coroutines.runCatching(dispatchers.io) { + api.getCoins( + active = true, // TODO change when voting functionality is implemented + searchText = searchText, + offset = page * params.loadSize, + limit = params.loadSize, + ) + }.fold( + onSuccess = { response -> + val coinsIds = response.coins.map { coin -> + CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(coin.id), + suffix = CryptoCurrency.ID.Suffix.RawID(coin.id), + ) + } + val quotes = quotesRepository.getQuotesSync(currenciesIds = coinsIds.toSet(), refresh = false) + + LoadResult.Page( + data = CoinsResponseConverter.convert( + CoinsData( + response.coins, + response.imageHost, + quotes, + ), + ), + prevKey = if (page == 0) null else page.minus(other = 1), + nextKey = if (response.coins.isEmpty()) null else page.plus(other = 1), + ) + }, + onFailure = { LoadResult.Error(it) }, + ) + } +} + +data class CoinsData( + val coins: List, + val imageHost: String?, + val quotes: Set, +) \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt new file mode 100644 index 0000000000..7735b24528 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt @@ -0,0 +1,51 @@ +package com.tangem.data.tokens.paging + +import com.tangem.blockchain.common.Blockchain +import com.tangem.data.tokens.utils.getNetworkStandardType +import com.tangem.datasource.api.tangemTech.models.CoinsResponse +import com.tangem.domain.common.extensions.fromNetworkId +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Token +import com.tangem.utils.converter.Converter + +/** + * Converter from data model [CoinsResponse] to list of domain models [Token] + */ +internal object CoinsResponseConverter : Converter> { + + override fun convert(value: CoinsData): List { + return value.coins.map { coin -> + val id = CryptoCurrency.ID( + CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + CryptoCurrency.ID.Body.NetworkId(coin.id), + CryptoCurrency.ID.Suffix.RawID(coin.id), + ) + val quote = value.quotes.find { it.rawCurrencyId == id.rawCurrencyId } + Token( + id = id.rawCurrencyId ?: coin.name, + name = coin.name, + symbol = coin.symbol, + iconUrl = getIconUrl(coin.id, value.imageHost), + isAvailable = coin.active, + networks = coin.networks.mapNotNull { network -> + val blockchain = Blockchain.fromNetworkId(network.networkId) ?: return@mapNotNull null + Token.Network( + networkId = network.networkId, + standardType = getNetworkStandardType(blockchain).name, + address = network.contractAddress, + iconUrl = getIconUrl(network.networkId, value.imageHost), + decimalCount = network.decimalCount?.toInt(), + ) + }, + quote = quote, + ) + } + } +} + +internal fun getIconUrl(id: String, imageHost: String? = null): String { + return "${imageHost ?: DEFAULT_IMAGE_HOST}large/$id.png" +} + +private const val DEFAULT_IMAGE_HOST = + "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/" \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokensListRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokensListRepository.kt new file mode 100644 index 0000000000..d4f2d9a9a2 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokensListRepository.kt @@ -0,0 +1,45 @@ +package com.tangem.data.tokens.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.tangem.data.tokens.paging.CoinsPagingSource +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.domain.tokens.model.Token +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.tokens.repository.TokensListRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.Flow + +/** + * Default repository implementation for managing operations related to a complete set of tokens + * + * @property tangemTechApi Tangem Tech API + * @property dispatchers coroutine dispatchers provider + * @property quotesRepository responsible for providing cryptocurrency quotes data + * + */ +internal class DefaultTokensListRepository( + private val tangemTechApi: TangemTechApi, + private val dispatchers: CoroutineDispatcherProvider, + private val quotesRepository: QuotesRepository, +) : TokensListRepository { + + override fun getTokens(searchText: String?): Flow> { + return Pager( + config = PagingConfig( + pageSize = 100, + prefetchDistance = 70, + enablePlaceholders = false, + ), + pagingSourceFactory = { + CoinsPagingSource( + api = tangemTechApi, + dispatchers = dispatchers, + searchText = searchText, + quotesRepository = quotesRepository, + ) + }, + ).flow + } +} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt index 5e87555133..21ef628101 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt @@ -50,7 +50,7 @@ private fun getNetworkDerivationPath( } } -private fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType { +internal fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType { return when (blockchain) { Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.StandardType.ERC20 Blockchain.BSC, Blockchain.BSCTestnet -> Network.StandardType.BEP20 diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index c146658684..8fabb669f0 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -22,6 +22,9 @@ dependencies { /** Project - Other */ implementation(projects.core.utils) + /** Android - Other */ + implementation(deps.androidx.paging.runtime) + /** Utils */ implementation(deps.jodatime) implementation(deps.reKotlin) diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt index 899574d5e4..9f5dfbe387 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt @@ -3,14 +3,17 @@ package com.tangem.domain.tokens.model import java.math.BigDecimal /** - * Represents a financial quote for a specific cryptocurrency, including its fiat exchange rate and price change. + * Represents financial information for a specific cryptocurrency, including its fiat exchange rate and price change. * - * @property rawCurrencyId The unique identifier of the token for which the quote is provided. + * @property rawCurrencyId The unique identifier of the cryptocurrency for which the financial information is provided. * @property fiatRate The current fiat exchange rate for the cryptocurrency. * @property priceChange The price change for the cryptocurrency. + * @property values The values representing the cryptocurrency's price changes over a 24-hour period, + * suitable for chart plotting. */ data class Quote( val rawCurrencyId: String, val fiatRate: BigDecimal, val priceChange: BigDecimal, + val values: List? = null, ) \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt new file mode 100644 index 0000000000..ed26e356b8 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.tokens.model + +/** + * Represents a domain model for a token (as part of a general list of tokens). + * + * @property id The unique identifier of the token. + * @property networks List of networks associated with the token. + * @property isAvailable Indicates whether this token is supported in Tangem app. + * @property quote Equivalent prices for the token. + * @property name The name of the token. + * @property symbol The brief name of the token, e.g., "BTC". + * @property iconUrl URL of the token's icon. + */ +data class Token( + val id: String, + val networks: List, + val isAvailable: Boolean, + val quote: Quote?, + val name: String, + val symbol: String, + val iconUrl: String, +) { + + /** + * Represents a domain model for a network associated with a token. + * + * @property networkId The unique identifier of the network. + * @property standardType The type of blockchain associated with the network. + * @property address The contract address of the token on the current network. + * It is null for the main currencies of the network. + * @property iconUrl URL of the network's icon. + * @property decimalCount The decimal count associated with the token on the network. + */ + data class Network( + val networkId: String, + val standardType: String, + val address: String?, + val iconUrl: String, + val decimalCount: Int?, + ) +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetGlobalTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetGlobalTokenListUseCase.kt new file mode 100644 index 0000000000..74ddd5b4e4 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetGlobalTokenListUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.tokens + +import androidx.paging.PagingData +import com.tangem.domain.tokens.model.Token +import com.tangem.domain.tokens.repository.TokensListRepository +import kotlinx.coroutines.flow.Flow + +/** + * Use case for retrieving a complete set of tokens with their quotes (filtered by a search text if provided). + * + * @property repository The repository responsible for fetching and preparing the list of tokens with their quotes. + */ +class GetGlobalTokenListUseCase(private val repository: TokensListRepository) { + + /** + * @param searchText The text used for filtering the list of tokens (not required). + * @return A [Flow] emitting [PagingData] containing the tokens with their quotes. + */ + operator fun invoke(searchText: String?): Flow> { + return repository.getTokens(searchText = searchText?.ifBlank(defaultValue = { null })) + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt new file mode 100644 index 0000000000..96db81b6af --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.tokens.repository + +import androidx.paging.PagingData +import com.tangem.domain.tokens.model.Token +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface for managing operations related to a complete set of tokens + * (not associated with any specific card). + * */ +interface TokensListRepository { + + /** + * Retrieves a list of available tokens with quotes, can be filtered based on the provided search text. + * + * @param searchText The search text used to filter tokens. + * @return A [Flow] emitting [PagingData] containing the tokens with quotes matching the search criteria. + */ + fun getTokens(searchText: String?): Flow> +} \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 76bd921f71..fab77971a3 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -47,6 +47,9 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.utils) + /** Project - Data */ + implementation(projects.data.tokens) + /** Domain modules */ implementation(projects.common) implementation(projects.domain.card)