Updated on 2026-08-14

This commit is contained in:
Tangem 2023-04-11 19:24:29 +09:00
parent a3a0d325da
commit c683506ffa
16 changed files with 389 additions and 76 deletions

View file

@ -40,6 +40,7 @@ dependencies {
implementation(deps.androidx.constraintLayout)
implementation(deps.androidx.activity.compose)
implementation(deps.androidx.browser)
implementation(deps.androidx.paging.runtime)
implementation(deps.lifecycle.runtime.ktx)
implementation(deps.lifecycle.common.java8)
implementation(deps.lifecycle.viewModel.ktx)
@ -56,6 +57,7 @@ dependencies {
implementation(deps.compose.shimmer)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.paging)
/** Firebase libraries */
implementation(platform(deps.firebase.bom))

View file

@ -20,10 +20,10 @@ class LoadAvailableCoinsService(
MoshiConverter.networkMoshi.adapter(CurrenciesFromJson::class.java)
suspend fun getSupportedTokens(
isTestNet: Boolean = false,
isTestNet: Boolean,
supportedBlockchains: List<Blockchain>,
page: Int,
searchInput: String? = null,
searchInput: String?,
): Result<LoadedCoins> {
if (isTestNet) {
return Result.Success(
@ -33,15 +33,17 @@ class LoadAvailableCoinsService(
),
)
}
val offset = page * LOAD_PER_PAGE
val result = loadCoins(supportedBlockchains, offset, searchInput)
return when (result) {
val offset = page * LOAD_PER_PAGE
return when (val result = loadCoins(supportedBlockchains, offset, searchInput)) {
is Result.Success -> {
val data = result.data
Result.Success(
LoadedCoins(
currencies = data.coins.map { Currency.fromCoinResponse(it, data.imageHost) },
currencies = data.coins.map {
Currency.fromCoinResponse(currency = it, imageHost = data.imageHost)
},
moreAvailable = data.total > offset + LOAD_PER_PAGE,
),
)
@ -55,22 +57,24 @@ class LoadAvailableCoinsService(
private suspend fun loadCoins(
supportedBlockchains: List<Blockchain>,
offset: Int,
searchInput: String? = null,
searchInput: String?,
): Result<CoinsResponse> {
return withContext(dispatchers.io) {
runCatching {
tangemTechApi.getCoins(
networkIds = supportedBlockchains.toSet().joinToString(",", transform = Blockchain::toNetworkId),
networkIds = supportedBlockchains.joinToString(
separator = ",",
transform = Blockchain::toNetworkId,
),
active = true,
searchText = searchInput,
offset = offset,
limit = LOAD_PER_PAGE,
)
}
.onSuccess { return@withContext Result.Success(it) }
.onFailure { return@withContext Result.Failure(it) }
error("Unreachable code because runCatching must return result")
}.fold(
onSuccess = { Result.Success(it) },
onFailure = { Result.Failure(it) },
)
}
}
@ -83,15 +87,15 @@ class LoadAvailableCoinsService(
private fun List<Currency>.filter(searchInput: String?): List<Currency> {
if (searchInput.isNullOrBlank()) return this
return filter {
it.symbol.contains(searchInput, ignoreCase = true) ||
it.name.contains(searchInput, ignoreCase = true)
return filter { currency ->
currency.symbol.contains(searchInput, ignoreCase = true) ||
currency.name.contains(searchInput, ignoreCase = true)
}
}
companion object {
private companion object {
const val LOAD_PER_PAGE = 100
private const val FILE_NAME_TESTNET_COINS = "testnet_tokens"
const val FILE_NAME_TESTNET_COINS = "testnet_tokens"
}
}

View file

@ -0,0 +1,53 @@
package com.tangem.tap.features.tokens.data
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.tap.features.tokens.domain.TokensListRepository
import com.tangem.tap.features.tokens.domain.models.Token
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
/**
* Default repository implementation of tokens list feature
*
* @property tangemTechApi Tangem Tech API
* @property dispatchers coroutine dispatchers provider
* @property reduxStateHolder redux state holder
* @property testnetTokensStorage storage for getting testnet tokens data
*
[REDACTED_AUTHOR]
*/
internal class DefaultTokensListRepository(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val reduxStateHolder: AppStateHolder,
private val testnetTokensStorage: TestnetTokensStorage,
) : TokensListRepository {
override fun getAvailableTokens(searchText: String?): Flow<PagingData<Token>> {
return Pager(
config = PagingConfig(
pageSize = 100,
prefetchDistance = 70,
enablePlaceholders = false,
),
pagingSourceFactory = {
if (reduxStateHolder.scanResponse?.card?.isTestCard == true) {
TestnetTokensPagingSource(testnetTokensStorage, searchText)
} else {
TangemApiTokensPagingSource(
api = tangemTechApi,
dispatchers = dispatchers,
reduxStateHolder = reduxStateHolder,
searchText = searchText,
)
}
},
).flow
}
}

View file

@ -0,0 +1,62 @@
package com.tangem.tap.features.tokens.data
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.common.extensions.supportedBlockchains
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.tap.features.tokens.data.converters.CoinsResponseConverter
import com.tangem.tap.features.tokens.domain.models.Token
import com.tangem.tap.proxy.AppStateHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
/**
* Paging source that get tokens by Tangem Tech API
*
* @property api Tangem Tech API
* @property dispatchers coroutine dispatchers provider
* @property reduxStateHolder redux state holder
* @property searchText search text
*/
internal class TangemApiTokensPagingSource(
private val api: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val reduxStateHolder: AppStateHolder,
private val searchText: String?,
) : PagingSource<Int, Token>() {
override fun getRefreshKey(state: PagingState<Int, Token>): 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<Int>): LoadResult<Int, Token> {
val page = params.key ?: 0
return runCatching(dispatchers.io) {
val supportedBlockchains = reduxStateHolder.scanResponse?.card?.supportedBlockchains()
?: Blockchain.values().toList()
api.getCoins(
networkIds = supportedBlockchains.joinToString(separator = ",", transform = Blockchain::toNetworkId),
active = true,
searchText = searchText,
offset = page * params.loadSize,
limit = params.loadSize,
)
}.fold(
onSuccess = { response ->
LoadResult.Page(
data = CoinsResponseConverter.convert(response),
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) },
)
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.tap.features.tokens.data
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import com.tangem.datasource.local.testnet.models.TestnetTokensConfig
import com.tangem.tap.features.tokens.data.converters.TestnetTokensConfigConverter
import com.tangem.tap.features.tokens.domain.models.Token
/**
* Paging source that get tokens by testnet tokens config
*
* @property testnetTokensStorage testnet tokens config storage
* @property searchText search text
*/
internal class TestnetTokensPagingSource(
private val testnetTokensStorage: TestnetTokensStorage,
private val searchText: String?,
) : PagingSource<Int, Token>() {
override fun getRefreshKey(state: PagingState<Int, Token>): Int? = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Token> {
return LoadResult.Page(
data = TestnetTokensConfigConverter.convert(
value = testnetTokensStorage.getConfig().searchByText(searchText),
),
prevKey = null,
nextKey = null,
)
}
private fun TestnetTokensConfig.searchByText(searchText: String?): TestnetTokensConfig {
if (searchText.isNullOrBlank()) return this
return copy(
tokens = tokens.filter { token ->
token.symbol.contains(other = searchText, ignoreCase = true) ||
token.name.contains(other = searchText, ignoreCase = true)
},
)
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.tap.features.tokens.data.converters
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.tap.domain.tokens.getIconUrl
import com.tangem.tap.features.tokens.domain.models.Token
import com.tangem.utils.converter.Converter
/**
* Converter from data model [CoinsResponse] to list of domain models [Token]
*
[REDACTED_AUTHOR]
*/
internal object CoinsResponseConverter : Converter<CoinsResponse, List<Token>> {
override fun convert(value: CoinsResponse): List<Token> {
return value.coins.map { token ->
Token(
id = token.id,
name = token.name,
symbol = token.symbol,
iconUrl = getIconUrl(token.id, value.imageHost),
networks = token.networks.mapNotNull { network ->
val blockchain = Blockchain.fromNetworkId(network.networkId) ?: return@mapNotNull null
Token.Network(
id = network.networkId,
blockchain = blockchain,
address = network.contractAddress,
iconUrl = getIconUrl(network.networkId, value.imageHost),
)
},
)
}
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.tap.features.tokens.data.converters
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.local.testnet.models.TestnetTokensConfig
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.tap.domain.tokens.getIconUrl
import com.tangem.tap.features.tokens.domain.models.Token
import com.tangem.utils.converter.Converter
/**
* Converter from data model [TestnetTokensConfig] to list of domain models [Token]
*
[REDACTED_AUTHOR]
*/
internal object TestnetTokensConfigConverter : Converter<TestnetTokensConfig, List<Token>> {
override fun convert(value: TestnetTokensConfig): List<Token> {
return value.tokens.map { token ->
Token(
id = token.id,
name = token.name,
symbol = token.symbol,
iconUrl = getIconUrl(id = token.id, imageHost = null),
networks = token.networks?.mapNotNull { network ->
val blockchain = Blockchain.fromNetworkId(network.id) ?: return@mapNotNull null
Token.Network(
id = network.id,
blockchain = blockchain,
address = network.address,
iconUrl = getIconUrl(id = network.id, imageHost = null),
)
}.orEmpty(),
)
}
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.tap.features.tokens.di
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.testnet.TestnetTokensStorage
import com.tangem.tap.features.tokens.data.DefaultTokensListRepository
import com.tangem.tap.features.tokens.domain.TokensListRepository
import com.tangem.tap.proxy.AppStateHolder
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
/**
[REDACTED_AUTHOR]
*/
@Module
@InstallIn(SingletonComponent::class)
internal object TokensListRepositoryModule {
@Provides
@Singleton
fun providesTokensListRepository(
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
reduxStateHolder: AppStateHolder,
testnetTokensStorage: TestnetTokensStorage,
): TokensListRepository {
return DefaultTokensListRepository(
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
reduxStateHolder = reduxStateHolder,
testnetTokensStorage = testnetTokensStorage,
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.tap.features.tokens.domain
import androidx.paging.PagingData
import com.tangem.tap.features.tokens.domain.models.Token
import kotlinx.coroutines.flow.Flow
/**
* Repository of tokens list feature
*
[REDACTED_AUTHOR]
*/
internal interface TokensListRepository {
/**
* Get available tokens list
*
* @param searchText search text
*/
fun getAvailableTokens(searchText: String?): Flow<PagingData<Token>>
}

View file

@ -0,0 +1,40 @@
package com.tangem.tap.features.tokens.domain.models
import com.tangem.blockchain.common.Blockchain
/**
* Domain model of token for tokens list screen
*
* @property id token id
* @property name token name
* @property symbol token brief name. Example, "BTC"
* @property iconUrl token icon url
* @property networks token networks
*
[REDACTED_AUTHOR]
*/
internal data class Token(
val id: String,
val name: String,
val symbol: String,
val iconUrl: String,
val networks: List<Network>,
) {
/**
* Domain model of network for tokens list screen
*
* @property id network id
* @property blockchain blockchain
* @property address address. If address equals null, it means it is the main network of token
* @property iconUrl network icon url
*
[REDACTED_AUTHOR]
*/
data class Network(
val id: String,
val blockchain: Blockchain,
val address: String?,
val iconUrl: String,
)
}

View file

@ -13,10 +13,7 @@ sealed class TokensAction : Action {
data class AllowToAddTokens(val allow: Boolean) : TokensAction()
data class LoadCurrencies(
val supportedBlockchains: List<Blockchain>? = null,
val scanResponse: ScanResponse? = null,
) : TokensAction() {
data class LoadCurrencies(val scanResponse: ScanResponse? = null) : TokensAction() {
data class Success(val currencies: List<Currency>, val loadMore: Boolean) : TokensAction()
object Failure : TokensAction()
}

View file

@ -76,11 +76,8 @@ class TokensMiddleware {
private fun handleLoadCurrencies(scanResponse: ScanResponse?, newSearchInput: String? = null) {
val tokensState = store.state.tokensState
val isTestcard = scanResponse?.card?.isTestCard ?: false
val supportedBlockchains: List<Blockchain> =
scanResponse?.card?.supportedBlockchains() ?: Blockchain.values().toList()
.filter { !it.isTestnet() }
val supportedBlockchains: List<Blockchain> = scanResponse?.card?.supportedBlockchains()
?: Blockchain.values().toList().filterNot(Blockchain::isTestnet)
val loadCoinsService = LoadAvailableCoinsService(
tangemTechApi = store.state.domainNetworks.tangemTechService.api,
@ -89,34 +86,26 @@ class TokensMiddleware {
)
scope.launch {
val loadCoinsResult = if (newSearchInput == null) {
loadCoinsService.getSupportedTokens(
isTestcard,
supportedBlockchains,
tokensState.pageToLoad,
tokensState.searchInput,
)
} else {
loadCoinsService.getSupportedTokens(
isTestNet = isTestcard,
supportedBlockchains = supportedBlockchains,
page = 0,
searchInput = newSearchInput.ifBlank { null },
)
}
when (loadCoinsResult) {
is Result.Success -> {
val currencies = loadCoinsResult.data.currencies
.filter(supportedBlockchains.toSet())
store.dispatchOnMain(
val result = loadCoinsService.getSupportedTokens(
isTestNet = scanResponse?.card?.isTestCard ?: false,
supportedBlockchains = supportedBlockchains,
page = if (newSearchInput == null) tokensState.pageToLoad else 0,
searchInput = if (newSearchInput == null) tokensState.searchInput else newSearchInput.ifBlank { null },
)
store.dispatchOnMain(
action = when (result) {
is Result.Success -> {
TokensAction.LoadCurrencies.Success(
currencies,
loadCoinsResult.data.moreAvailable,
),
)
}
is Result.Failure -> store.dispatchOnMain(TokensAction.LoadCurrencies.Failure)
}
currencies = result.data.currencies.filter(supportedBlockchains.toSet()),
loadMore = result.data.moreAvailable,
)
}
is Result.Failure -> {
TokensAction.LoadCurrencies.Failure
}
},
)
}
}

View file

@ -37,7 +37,6 @@ private fun internalReduce(action: Action, state: AppState): TokensState {
addedBlockchains = action.wallets.toNonCustomBlockchains(action.derivationStyle),
addedTokens = action.wallets.toNonCustomTokensWithBlockchains(action.derivationStyle),
addedWallets = action.wallets,
derivationStyle = action.derivationStyle,
)
}
is TokensAction.AllowToAddTokens -> {

View file

@ -18,7 +18,6 @@ data class TokensState(
val currencies: List<Currency> = emptyList(),
val searchInput: String? = null,
val allowToAdd: Boolean = true,
val derivationStyle: DerivationStyle? = null,
val scanResponse: ScanResponse? = null,
val needToLoadMore: Boolean = true,
val pageToLoad: Int = 0,
@ -67,19 +66,19 @@ data class TokenWithBlockchain(
fun List<Currency>.filter(supportedBlockchains: Set<Blockchain>?): List<Currency> {
if (supportedBlockchains == null) return this
return map {
it.copy(
contracts =
it.contracts.filter {
supportedBlockchains.contains(it.blockchain) &&
(it.blockchain.canHandleTokens() || it.address == null)
return map { currency ->
currency.copy(
contracts = currency.contracts.filter { contract ->
supportedBlockchains.contains(contract.blockchain) &&
(contract.blockchain.canHandleTokens() || contract.address == null)
},
)
}.filterNot {
it.contracts.isNullOrEmpty() && !supportedBlockchains.contains(Blockchain.fromNetworkId(it.id))
}.filterNot { currency ->
currency.contracts.isEmpty() && !supportedBlockchains.contains(Blockchain.fromNetworkId(currency.id))
}
}
enum class LoadCoinsState {
LOADING, LOADED, ERROR
LOADING, LOADED,
}

View file

@ -6,7 +6,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.badoo.mvicore.modelWatcher
import com.tangem.core.analytics.Analytics
import com.tangem.domain.common.TapWorkarounds.derivationStyle
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.tap.common.analytics.events.MainScreen
import com.tangem.tap.common.analytics.events.ManageTokens
import com.tangem.tap.common.analytics.events.Portfolio
@ -15,7 +14,6 @@ import com.tangem.tap.common.extensions.hide
import com.tangem.tap.common.extensions.show
import com.tangem.tap.common.redux.navigation.AppScreen
import com.tangem.tap.common.redux.navigation.NavigationAction
import com.tangem.tap.domain.tokens.CurrenciesRepository
import com.tangem.tap.features.tokens.redux.TokensAction
import com.tangem.tap.features.wallet.models.TotalBalance
import com.tangem.tap.features.wallet.redux.WalletAction
@ -110,15 +108,7 @@ class MultiWalletView : WalletView() {
binding.btnAddToken.setOnClickListener {
val card = store.state.globalState.scanResponse!!.card
Analytics.send(Portfolio.ButtonManageTokens())
store.dispatch(
TokensAction.LoadCurrencies(
supportedBlockchains = CurrenciesRepository.getBlockchains(
card.firmwareVersion,
card.isTestCard,
),
scanResponse = store.state.globalState.scanResponse,
),
)
store.dispatch(TokensAction.LoadCurrencies(scanResponse = store.state.globalState.scanResponse))
store.dispatch(TokensAction.AllowToAddTokens(true))
store.dispatch(
TokensAction.SetAddedCurrencies(

View file

@ -17,6 +17,7 @@ androidxConstraintLayout = "2.1.4"
androidxCore = "1.9.0"
androidxFragment = "1.5.3"
androidxLifecycle = "2.5.1"
androidx-paging = "3.1.1"
# endregion AndroidX
# region Compose
@ -27,6 +28,7 @@ compose-material = "1.3.1"
compose-constraint = "1.0.1"
compose-navigation = "2.5.3"
compose-accompanist = "0.28.0"
compose-paging = "1.0.0-alpha18"
# endregion Compose
# region Other libraries
@ -116,6 +118,7 @@ androidx-browser = { module = "androidx.browser:browser", version.ref = "android
androidx-constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidxConstraintLayout" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidxFragment" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidxLifecycle" }
lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
lifecycle-viewModel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidxLifecycle" }
@ -135,6 +138,7 @@ compose-navigation-hilt = { module = "androidx.hilt:hilt-navigation-compose", ve
compose-accompanist-appCompatTheme = { module = "com.google.accompanist:accompanist-appcompat-theme", version.ref = "compose-accompanist" }
compose-accompanist-systemUiController = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "compose-accompanist" }
compose-accompanist-webView = { module = "com.google.accompanist:accompanist-webview", version.ref = "compose-accompanist" }
compose-paging = { module = "androidx.paging:paging-compose", version.ref = "compose-paging" }
# endregion Compose
# region Firebase