Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-01 13:10:07 +05:00
parent aea7f5f415
commit c83ce2ada3
53 changed files with 1062 additions and 238 deletions

View file

@ -1,5 +1,6 @@
package com.tangem.tap.di.domain
import com.tangem.domain.nft.FetchNFTCollectionAssetsUseCase
import com.tangem.domain.nft.FetchNFTCollectionsUseCase
import com.tangem.domain.nft.GetNFTCollectionsUseCase
import com.tangem.domain.nft.repository.NFTRepository
@ -37,4 +38,12 @@ internal object NFTDomainModule {
nftRepository = nftRepository,
)
}
@Provides
@Singleton
fun providesFetchNFTCollectionAssetsUseCase(nftRepository: NFTRepository): FetchNFTCollectionAssetsUseCase {
return FetchNFTCollectionAssetsUseCase(
nftRepository = nftRepository,
)
}
}

View file

@ -12,6 +12,7 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensSource
import com.tangem.features.markets.details.MarketsTokenDetailsComponent
import com.tangem.features.nft.component.NFTCollectionsComponent
import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent
import com.tangem.features.onramp.component.*
import com.tangem.features.pushnotifications.api.PushNotificationsComponent
@ -80,6 +81,7 @@ internal class ChildFactory @Inject constructor(
private val sendComponentFactoryV2: com.tangem.features.send.v2.api.SendComponent.Factory,
private val sendFeatureToggles: SendFeatureToggles,
private val redesignedWalletConnectComponentFactory: RedisegnedWalletConnectComponent.Factory,
private val nftCollectionsComponentFactory: NFTCollectionsComponent.Factory,
private val testerRouter: TesterRouter,
private val routingFeatureToggles: RoutingFeatureToggles,
private val walletConnectFeatureToggles: WalletConnectFeatureToggles,
@ -397,6 +399,12 @@ internal class ChildFactory @Inject constructor(
componentFactory = walletComponentFactory,
)
}
is AppRoute.NFTCollections ->
createComponentChild(
context = context,
params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId),
componentFactory = nftCollectionsComponentFactory,
)
is AppRoute.OnboardingNote,
is AppRoute.SaveWallet,
is AppRoute.OnboardingOther,
@ -729,6 +737,12 @@ internal class ChildFactory @Inject constructor(
componentFactory = storiesComponentFactory,
)
}
is AppRoute.NFTCollections ->
route.asComponentChild(
contextProvider = contextProvider(route, contextFactory),
params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId),
componentFactory = nftCollectionsComponentFactory,
)
}
// endregion
}

View file

@ -277,4 +277,9 @@ sealed class AppRoute(val path: String) : Route {
val nextScreen: AppRoute,
val screenSource: String,
) : AppRoute(path = "/stories$storyId")
@Serializable
data class NFTCollections(
val userWalletId: UserWalletId,
) : AppRoute(path = "/nft_collections/${userWalletId.stringValue}")
}

View file

@ -42,7 +42,7 @@ internal class DefaultNFTRuntimeStore(
)
}
override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset> =
override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset?> =
collectionsRuntimeStore
.get()
.combine(getSalePrice(assetId)) { collectionsData, price ->
@ -50,13 +50,17 @@ internal class DefaultNFTRuntimeStore(
.getCollection(collectionId)
?.getAsset(assetId)
?.mergeWithPrice(price)
?: NFTAsset.Error(assetId)
}
override fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice> = pricesRuntimeStore
.get()
.map { it[assetId] ?: NFTSalePrice.Empty(assetId) }
override suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice = pricesRuntimeStore
.getSyncOrNull()
?.let { it[assetId] }
?: NFTSalePrice.Empty(assetId)
override suspend fun saveCollections(collections: NFTCollections) {
collectionsRuntimeStore.store(collections)
}
@ -72,8 +76,13 @@ internal class DefaultNFTRuntimeStore(
?.collections
?.firstOrNull { it.id == collectionId }
private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier): NFTAsset? =
assets.firstOrNull { it.id == assetId }
private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier): NFTAsset? = when (val assets = assets) {
is NFTCollection.Assets.Empty,
is NFTCollection.Assets.Loading,
is NFTCollection.Assets.Failed,
-> null
is NFTCollection.Assets.Value -> assets.items.firstOrNull { it.id == assetId }
}
private fun NFTCollections.mergeWithPrices(prices: Map<NFTAsset.Identifier, NFTSalePrice>): NFTCollections =
when (val content = this.content) {
@ -89,18 +98,23 @@ internal class DefaultNFTRuntimeStore(
copy(
collections = this.collections?.map { data ->
data.copy(
assets = data.assets.map { asset ->
asset.mergeWithPrice(prices[asset.id] ?: NFTSalePrice.Empty(asset.id))
assets = when (val assets = data.assets) {
is NFTCollection.Assets.Empty,
is NFTCollection.Assets.Loading,
is NFTCollection.Assets.Failed,
-> assets
is NFTCollection.Assets.Value -> assets.copy(
items = assets.items.map { asset ->
asset.mergeWithPrice(prices[asset.id] ?: NFTSalePrice.Empty(asset.id))
},
)
},
)
},
source = this.source,
)
private fun NFTAsset.mergeWithPrice(price: NFTSalePrice): NFTAsset = when (this) {
is NFTAsset.Error -> this
is NFTAsset.Value -> copy(
salePrice = price,
)
}
private fun NFTAsset.mergeWithPrice(price: NFTSalePrice): NFTAsset = copy(
salePrice = price,
)
}

View file

@ -16,7 +16,9 @@ interface NFTRuntimeStore {
fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow<NFTAsset?>
fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice?>
fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice>
suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice
suspend fun saveCollections(collections: NFTCollections)

View file

@ -12,7 +12,7 @@ object NFTSdkAssetConverter : Converter<Pair<Network, SdkNFTAsset>, NFTAsset> {
val (network, asset) = value
val assetId = NFTSdkAssetIdentifierConverter.convert(asset.identifier)
val collectionId = NFTSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier)
return NFTAsset.Value(
return NFTAsset(
id = assetId,
collectionId = collectionId,
network = network,
@ -25,23 +25,22 @@ object NFTSdkAssetConverter : Converter<Pair<Network, SdkNFTAsset>, NFTAsset> {
assetId = assetId,
value = it.value,
symbol = it.symbol,
source = StatusSource.CACHE,
)
} ?: NFTSalePrice.Empty(assetId = assetId),
rarity = asset.rarity?.let {
NFTAsset.Value.Rarity(
NFTAsset.Rarity(
rank = it.rank,
label = it.label,
)
},
media = asset.media?.let {
NFTAsset.Value.Media(
NFTAsset.Media(
url = it.url,
mimetype = it.mimetype,
)
},
traits = asset.traits.map {
NFTAsset.Value.Trait(
NFTAsset.Trait(
name = it.name,
value = it.value,
)

View file

@ -1,5 +1,6 @@
package com.tangem.datasource.local.nft.converter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.tokens.model.Network
@ -23,6 +24,16 @@ object NFTSdkCollectionConverter : Converter<Pair<Network, SdkNFTCollection>, NF
}
.filter {
it.id !is NFTAsset.Identifier.Unknown
}
.let {
if (it.isEmpty()) {
NFTCollection.Assets.Empty
} else {
NFTCollection.Assets.Value(
items = it,
source = StatusSource.CACHE,
)
}
},
)
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.onramp.utils
package com.tangem.core.ui.components.fields
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.withDebounce
@ -12,7 +12,7 @@ import javax.inject.Inject
*
[REDACTED_AUTHOR]
*/
internal class InputManager @Inject constructor() {
class InputManager @Inject constructor() {
val query: Flow<String>
get() = _query

View file

@ -9,6 +9,7 @@ import com.tangem.datasource.local.nft.NFTRuntimeStore
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
@ -24,9 +25,12 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
@Suppress("LargeClass")
internal class DefaultNFTRepository @Inject constructor(
private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory,
private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory,
@ -34,10 +38,11 @@ internal class DefaultNFTRepository @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) : NFTRepository {
private val jobs = mutableMapOf<Network, JobHolder>()
private val networkJobs = ConcurrentHashMap<Network, JobHolder>()
private val collectionJobs = ConcurrentHashMap<NFTCollection.Identifier, JobHolder>()
private val nftRuntimeStores = mutableMapOf<String, NFTRuntimeStore>()
private val nftPersistenceStores = mutableMapOf<String, NFTPersistenceStore>()
private val nftRuntimeStores = ConcurrentHashMap<String, NFTRuntimeStore>()
private val nftPersistenceStores = ConcurrentHashMap<String, NFTPersistenceStore>()
override fun observeCollections(userWalletId: UserWalletId, networks: List<Network>): Flow<List<NFTCollections>> =
flow { emitAll(observeCollectionsInternal(userWalletId, networks)) }
@ -64,15 +69,9 @@ internal class DefaultNFTRepository @Inject constructor(
launch(dispatchers.io) {
Either.catch {
expireCollections(userWalletId, network)
walletManagersFacade.getNFTCollections(userWalletId, network)
}.onLeft {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
}.onRight {
val mergedCollections = it.mergeWithStoredAssets(userWalletId, network)
val collections = walletManagersFacade.getNFTCollections(userWalletId, network)
val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network)
saveCollectionsInRuntime(
userWalletId = userWalletId,
@ -84,14 +83,112 @@ internal class DefaultNFTRepository @Inject constructor(
network = network,
collections = mergedCollections,
)
}.onLeft {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
}
}.saveIn(getJobHolder(network))
}.saveIn(getNetworkJobHolder(network))
} else {
null
}
}.joinAll()
}
override suspend fun refreshAssets(
userWalletId: UserWalletId,
network: Network,
collectionId: NFTCollection.Identifier,
) = coroutineScope {
launch(dispatchers.io) {
Either.catch {
expireAssets(userWalletId, network, collectionId)
val sdkCollectionId = NFTSdkCollectionIdentifierConverter.convertBack(collectionId)
val assets = walletManagersFacade.getNFTAssets(
userWalletId = userWalletId,
network = network,
collectionIdentifier = sdkCollectionId,
)
assets.forEach {
val assetId = NFTSdkAssetIdentifierConverter.convert(it.identifier)
val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId)
if (price is NFTSalePrice.Error) {
refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier)
}
}
getNFTPersistenceStore(userWalletId, network)
.getCollectionsSync()
?.map {
if (it.identifier == sdkCollectionId) {
it.copy(assets = assets)
} else {
it
}
}
?.let {
saveCollectionsInRuntime(
userWalletId = userWalletId,
network = network,
collections = it,
)
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = it,
)
}
}.onLeft {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
}
}.saveIn(getCollectionJobHolder(collectionId)).join()
}
private suspend fun refreshSalePrice(
userWalletId: UserWalletId,
network: Network,
sdkCollectionId: SdkNFTCollection.Identifier,
sdkAssetId: SdkNFTAsset.Identifier,
) = coroutineScope {
launch(dispatchers.io) {
val assetId = NFTSdkAssetIdentifierConverter.convert(sdkAssetId)
Either.catch {
saveSalePriceInRuntime(userWalletId, network, NFTSalePrice.Loading(assetId))
val sdkSalePrice =
walletManagersFacade.getNFTSalePrice(userWalletId, network, sdkCollectionId, sdkAssetId)
val salePrice = if (sdkSalePrice == null) {
NFTSalePrice.Empty(assetId)
} else {
NFTSalePrice.Value(
assetId = assetId,
value = sdkSalePrice.value,
symbol = sdkSalePrice.symbol,
)
}
saveSalePriceInRuntime(userWalletId, network, salePrice)
sdkSalePrice?.let {
saveSalePriceInPersistence(userWalletId, network, sdkAssetId, it)
}
}.onLeft {
saveSalePriceInRuntime(userWalletId, network, NFTSalePrice.Error(assetId))
}
}
}
private suspend fun expireCollections(userWalletId: UserWalletId, network: Network) {
val runtimeStore = getNFTRuntimeStore(userWalletId, network)
val expiredCollections = runtimeStore
@ -100,6 +197,23 @@ internal class DefaultNFTRepository @Inject constructor(
runtimeStore.saveCollections(expiredCollections)
}
private suspend fun expireAssets(
userWalletId: UserWalletId,
network: Network,
collectionId: NFTCollection.Identifier,
) {
val runtimeStore = getNFTRuntimeStore(userWalletId, network)
val storedCollections = runtimeStore.getCollectionsSync()
val expiredCollections = storedCollections
.changeCollectionAssetsStatusSource(collectionId, StatusSource.CACHE)
.let {
storedCollections.copy(
content = it,
)
}
runtimeStore.saveCollections(expiredCollections)
}
private suspend fun saveCollectionsInRuntime(
userWalletId: UserWalletId,
network: Network,
@ -126,7 +240,9 @@ internal class DefaultNFTRepository @Inject constructor(
getNFTRuntimeStore(userWalletId, network).let { store ->
val storedCollections = store.getCollectionsSync()
val content = storedCollections.content
val updatedCollections = if (content is NFTCollections.Content.Collections && content.collections != null) {
val updatedCollections = if (content is NFTCollections.Content.Collections &&
!content.collections.isNullOrEmpty()
) {
// if there is any cached collections in store, then mark them as not actual and emit anyway
storedCollections.changeStatusSource(StatusSource.ONLY_CACHE)
} else {
@ -148,15 +264,35 @@ internal class DefaultNFTRepository @Inject constructor(
getNFTPersistenceStore(userWalletId, network).saveCollections(collections)
}
private fun getJobHolder(network: Network): JobHolder = jobs[network] ?: run {
private suspend fun saveSalePriceInRuntime(userWalletId: UserWalletId, network: Network, salePrice: NFTSalePrice) {
getNFTRuntimeStore(userWalletId, network).saveSalePrice(salePrice)
}
private suspend fun saveSalePriceInPersistence(
userWalletId: UserWalletId,
network: Network,
assetId: SdkNFTAsset.Identifier,
salePrice: SdkNFTAsset.SalePrice,
) {
getNFTPersistenceStore(userWalletId, network).saveSalePrice(assetId, salePrice)
}
private fun getNetworkJobHolder(network: Network): JobHolder = networkJobs.getOrPut(network) {
JobHolder().also {
jobs[network] = it
networkJobs[network] = it
}
}
private fun getCollectionJobHolder(collectionId: NFTCollection.Identifier): JobHolder =
collectionJobs.getOrPut(collectionId) {
JobHolder().also {
collectionJobs[collectionId] = it
}
}
private fun getNFTPersistenceStore(userWalletId: UserWalletId, network: Network): NFTPersistenceStore {
val storeId = (userWalletId to network).formatted()
return nftPersistenceStores[storeId] ?: run {
return nftPersistenceStores.getOrPut(storeId) {
nftPersistenceStoreFactory.provide(userWalletId, network).also {
nftPersistenceStores[storeId] = it
}
@ -165,7 +301,7 @@ internal class DefaultNFTRepository @Inject constructor(
private suspend fun getNFTRuntimeStore(userWalletId: UserWalletId, network: Network): NFTRuntimeStore {
val storeId = (userWalletId to network).formatted()
return nftRuntimeStores[storeId] ?: run {
return nftRuntimeStores.getOrPut(storeId) {
nftRuntimeStoreFactory.provide(network).also {
nftRuntimeStores[storeId] = it
val storedCollections = getStoredCollections(userWalletId, network)
@ -213,7 +349,6 @@ internal class DefaultNFTRepository @Inject constructor(
assetId = assetId,
value = price.value,
symbol = price.symbol,
source = StatusSource.CACHE,
)
}
}
@ -227,6 +362,37 @@ internal class DefaultNFTRepository @Inject constructor(
},
)
private fun NFTCollections.changeCollectionAssetsStatusSource(
collectionId: NFTCollection.Identifier,
source: StatusSource,
) = when (val content = content) {
is NFTCollections.Content.Collections ->
content
.copy(
collections = content
.collections
?.map {
if (it.id == collectionId) {
it.changeAssetsStatusSource(source)
} else {
it
}
},
)
is NFTCollections.Content.Error -> content
}
private fun NFTCollection.changeAssetsStatusSource(source: StatusSource) = copy(
assets = when (val assets = this.assets) {
is NFTCollection.Assets.Empty -> NFTCollection.Assets.Loading
is NFTCollection.Assets.Loading -> assets
is NFTCollection.Assets.Failed -> assets
is NFTCollection.Assets.Value -> assets.copy(
source = source,
)
},
)
private suspend fun List<SdkNFTCollection>.mergeWithStoredAssets(
userWalletId: UserWalletId,
network: Network,

View file

@ -667,7 +667,7 @@ class DefaultWalletManagersFacade(
return walletManager.getAssets(address, collectionIdentifier)
}
override suspend fun getAsset(
override suspend fun getNFTAsset(
userWalletId: UserWalletId,
network: Network,
collectionIdentifier: NFTCollection.Identifier,
@ -682,6 +682,21 @@ class DefaultWalletManagersFacade(
return walletManager.getAsset(collectionIdentifier, assetIdentifier)
}
override suspend fun getNFTSalePrice(
userWalletId: UserWalletId,
network: Network,
collectionIdentifier: NFTCollection.Identifier,
assetIdentifier: NFTAsset.Identifier,
): NFTAsset.SalePrice? {
val blockchain = Blockchain.fromId(network.id.value)
val walletManager = getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = blockchain,
derivationPath = network.derivationPath.value,
) ?: return null
return walletManager.getSalePrice(collectionIdentifier, assetIdentifier)
}
override suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean {
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network)
val initializableAccountWalletManger = walletManager as? InitializableAccount ?: return true

View file

@ -259,13 +259,20 @@ interface WalletManagersFacade {
collectionIdentifier: NFTCollection.Identifier,
): List<NFTAsset>
suspend fun getAsset(
suspend fun getNFTAsset(
userWalletId: UserWalletId,
network: Network,
collectionIdentifier: NFTCollection.Identifier,
assetIdentifier: NFTAsset.Identifier,
): NFTAsset?
suspend fun getNFTSalePrice(
userWalletId: UserWalletId,
network: Network,
collectionIdentifier: NFTCollection.Identifier,
assetIdentifier: NFTAsset.Identifier,
): NFTAsset.SalePrice?
/**
* If wallet manager implements [InitializableAccount] then returns [InitializableAccount.isAccountInitialized]
* value. Otherwise always return true

View file

@ -3,43 +3,35 @@ package com.tangem.domain.nft.models
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.Network
sealed class NFTAsset {
abstract val id: Identifier
data class NFTAsset(
val id: Identifier,
val collectionId: NFTCollection.Identifier,
val network: Network,
val contractType: String,
val owner: String?,
val name: String?,
val description: String?,
val salePrice: NFTSalePrice,
val rarity: Rarity?,
val media: Media?,
val traits: List<Trait>,
val source: StatusSource,
) {
data class Value(
override val id: Identifier,
val collectionId: NFTCollection.Identifier,
val network: Network,
val contractType: String,
val owner: String?,
val name: String?,
val description: String?,
val salePrice: NFTSalePrice,
val rarity: Rarity?,
val media: Media?,
val traits: List<Trait>,
val source: StatusSource,
) : NFTAsset() {
data class Media(
val mimetype: String?,
val url: String,
)
data class Media(
val mimetype: String,
val url: String,
)
data class Rarity(
val rank: String,
val label: String,
)
data class Rarity(
val rank: String,
val label: String,
)
data class Trait(
val name: String,
val value: String,
)
}
data class Error(
override val id: Identifier,
) : NFTAsset()
data class Trait(
val name: String,
val value: String,
)
sealed class Identifier {
data class EVM(

View file

@ -1,5 +1,6 @@
package com.tangem.domain.nft.models
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.Network
data class NFTCollection(
@ -9,8 +10,18 @@ data class NFTCollection(
val description: String?,
val logoUrl: String?,
val count: Int,
val assets: List<NFTAsset> = emptyList(),
val assets: Assets,
) {
sealed class Assets {
data object Empty : Assets()
data object Loading : Assets()
data object Failed : Assets()
data class Value(
val items: List<NFTAsset>,
val source: StatusSource,
) : Assets()
}
sealed class Identifier {
data class EVM(val tokenAddress: String) : Identifier()
data class TON(val contractAddress: String?) : Identifier()

View file

@ -27,4 +27,31 @@ data class NFTCollections(
),
)
}
}
fun List<NFTCollections>.allCollectionsFailed() = this.all {
it.content is NFTCollections.Content.Error
}
fun List<NFTCollections>.anyCollectionFailed() = this.any {
it.content is NFTCollections.Content.Error ||
it.content is NFTCollections.Content.Collections &&
it.content.source == StatusSource.ONLY_CACHE
}
fun List<NFTCollections>.allLoadedCollectionsEmpty() = this
.map { it.content }
.filterIsInstance<NFTCollections.Content.Collections>()
.all { it.collections.isNullOrEmpty() }
fun List<NFTCollections>.allCollectionsLoaded() = this.all {
val content = it.content
content is NFTCollections.Content.Collections &&
content.source != StatusSource.CACHE
}
fun List<NFTCollections>.allCollectionsEmpty() = this.all {
val content = it.content
content is NFTCollections.Content.Collections &&
content.collections.isNullOrEmpty()
}

View file

@ -1,6 +1,5 @@
package com.tangem.domain.nft.models
import com.tangem.domain.models.StatusSource
import java.math.BigDecimal
sealed class NFTSalePrice {
@ -10,6 +9,10 @@ sealed class NFTSalePrice {
override val assetId: NFTAsset.Identifier,
) : NFTSalePrice()
data class Loading(
override val assetId: NFTAsset.Identifier,
) : NFTSalePrice()
data class Error(
override val assetId: NFTAsset.Identifier,
) : NFTSalePrice()
@ -18,6 +21,5 @@ sealed class NFTSalePrice {
override val assetId: NFTAsset.Identifier,
val value: BigDecimal,
val symbol: String,
val source: StatusSource,
) : NFTSalePrice()
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.nft
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.repository.NFTRepository
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
class FetchNFTCollectionAssetsUseCase(
private val nftRepository: NFTRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network, collectionId: NFTCollection.Identifier) {
nftRepository.refreshAssets(userWalletId, network, collectionId)
}
}

View file

@ -14,7 +14,7 @@ class GetNFTCollectionsUseCase(
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun launch(userWalletId: UserWalletId): Flow<List<NFTCollections>> = currenciesRepository
operator fun invoke(userWalletId: UserWalletId): Flow<List<NFTCollections>> = currenciesRepository
.getWalletCurrenciesUpdates(userWalletId)
.flatMapLatest {
val networks = it

View file

@ -1,5 +1,6 @@
package com.tangem.domain.nft.repository
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
@ -9,4 +10,6 @@ interface NFTRepository {
fun observeCollections(userWalletId: UserWalletId, networks: List<Network>): Flow<List<NFTCollections>>
suspend fun refreshCollections(userWalletId: UserWalletId, networks: List<Network>)
suspend fun refreshAssets(userWalletId: UserWalletId, network: Network, collectionId: NFTCollection.Identifier)
}

View file

@ -12,6 +12,7 @@ dependencies {
/* Project - Domain */
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
/* Project - Core */
implementation(projects.core.decompose)

View file

@ -0,0 +1,14 @@
package com.tangem.features.nft.component
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.wallets.models.UserWalletId
interface NFTCollectionsComponent : ComposableContentComponent {
data class Params(
val userWalletId: UserWalletId,
)
interface Factory : ComponentFactory<Params, NFTCollectionsComponent>
}

View file

@ -27,7 +27,10 @@ dependencies {
implementation(projects.core.datasource)
/** Domain modules */
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
/** Common */
implementation(projects.common.ui)

View file

@ -1,10 +1,17 @@
package com.tangem.features.nft
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.core.decompose.model.Model
import com.tangem.features.nft.collections.DefaultNFTCollectionsComponent
import com.tangem.features.nft.collections.model.NFTCollectionsModel
import com.tangem.features.nft.component.NFTCollectionsComponent
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@ -16,4 +23,17 @@ internal object NFTFeatureModule {
fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): NFTFeatureToggles {
return DefaultNFTFeatureToggles(featureTogglesManager)
}
}
@Module
@InstallIn(SingletonComponent::class)
internal interface NFTFeatureModuleBinds {
@Binds
@Singleton
fun bindComponentFactory(impl: DefaultNFTCollectionsComponent.Factory): NFTCollectionsComponent.Factory
@Binds
@IntoMap
@ClassKey(NFTCollectionsModel::class)
fun bindModel(model: NFTCollectionsModel): Model
}

View file

@ -0,0 +1,37 @@
package com.tangem.features.nft.collections
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.nft.collections.model.NFTCollectionsModel
import com.tangem.features.nft.collections.ui.NFTCollections
import com.tangem.features.nft.component.NFTCollectionsComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultNFTCollectionsComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: NFTCollectionsComponent.Params,
) : NFTCollectionsComponent, AppComponentContext by context {
private val model: NFTCollectionsModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
NFTCollections(state, modifier)
}
@AssistedFactory
interface Factory : NFTCollectionsComponent.Factory {
override fun create(
context: AppComponentContext,
params: NFTCollectionsComponent.Params,
): DefaultNFTCollectionsComponent
}
}

View file

@ -5,12 +5,8 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class NFTCollectionAssetsListUM {
data object Collapsed : NFTCollectionAssetsListUM()
@Immutable
sealed class Expanded : NFTCollectionAssetsListUM() {
data class Loading(val itemsCount: Int) : Expanded()
data class Failed(val onRetryClick: () -> Unit) : Expanded()
data class Content(val items: ImmutableList<NFTCollectionAssetUM>) : Expanded()
}
data object Init : NFTCollectionAssetsListUM()
data class Loading(val itemsCount: Int) : NFTCollectionAssetsListUM()
data class Failed(val onRetryClick: () -> Unit) : NFTCollectionAssetsListUM()
data class Content(val items: ImmutableList<NFTCollectionAssetUM>) : NFTCollectionAssetsListUM()
}

View file

@ -10,5 +10,6 @@ internal data class NFTCollectionUM(
val logoUrl: String?,
val description: TextReference,
val assets: NFTCollectionAssetsListUM,
val isExpanded: Boolean,
val onExpandClick: () -> Unit,
)

View file

@ -0,0 +1,6 @@
package com.tangem.features.nft.collections.entity
internal data class NFTCollectionsStateUM(
val onBackClick: () -> Unit,
val content: NFTCollectionsUM,
)

View file

@ -2,4 +2,7 @@ package com.tangem.features.nft.collections.entity
import com.tangem.core.ui.components.notifications.NotificationConfig
internal data class NFTCollectionsWarningUM(val config: NotificationConfig)
internal data class NFTCollectionsWarningUM(
val id: String,
val config: NotificationConfig,
)

View file

@ -0,0 +1,34 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toPersistentList
internal class ChangeCollectionExpandedStateTransformer(
private val collectionId: NFTCollection.Identifier,
private val onFirstExpanded: () -> Unit,
) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when (prevState.content) {
is NFTCollectionsUM.Empty,
is NFTCollectionsUM.Loading,
is NFTCollectionsUM.Failed,
-> prevState.content
is NFTCollectionsUM.Content -> prevState.content.copy(
collections = prevState.content.collections.map {
if (it.id == collectionId.toString()) {
if (!it.isExpanded) {
onFirstExpanded()
}
it.copy(isExpanded = !it.isExpanded)
} else {
it
}
}.toPersistentList(),
)
},
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.utils.transformer.Transformer
internal class ToggleSearchBarTransformer(private val isActive: Boolean) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when (val content = prevState.content) {
is NFTCollectionsUM.Content -> content.copy(
search = content.search.copy(
isActive = isActive,
),
)
is NFTCollectionsUM.Empty,
is NFTCollectionsUM.Loading,
is NFTCollectionsUM.Failed,
-> content
},
)
}

View file

@ -0,0 +1,134 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.nft.models.*
import com.tangem.features.nft.collections.entity.*
import com.tangem.features.nft.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Suppress("LongParameterList")
internal class UpdateDataStateTransformer(
private val nftCollections: List<NFTCollections>,
private val searchQuery: String,
private val onReceiveClick: () -> Unit,
private val onRetryClick: () -> Unit,
private val onExpandCollectionClick: (NFTCollection) -> Unit,
private val onRetryAssetsClick: (NFTCollection) -> Unit,
private val onAssetClick: (NFTAsset) -> Unit,
private val initialSearchBarFactory: () -> SearchBarUM,
) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when {
nftCollections.allCollectionsFailed() ->
NFTCollectionsUM.Failed(onRetryClick, onReceiveClick)
nftCollections.anyCollectionFailed() && nftCollections.allLoadedCollectionsEmpty() ->
NFTCollectionsUM.Failed(onRetryClick, onReceiveClick)
nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() ->
NFTCollectionsUM.Empty(onReceiveClick)
!nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() ->
NFTCollectionsUM.Loading(onReceiveClick)
else -> {
NFTCollectionsUM.Content(
search = if (prevState.content is NFTCollectionsUM.Content) {
prevState.content.search.copy(
query = searchQuery,
)
} else {
initialSearchBarFactory()
},
collections = nftCollections
.map { it.content }
.asSequence()
.filterIsInstance<NFTCollections.Content.Collections>()
.map { it.collections.orEmpty().transform(prevState, searchQuery) }
.flatten()
.toPersistentList(),
warnings = transformNotifications(),
onReceiveClick = onReceiveClick,
)
}
},
)
private fun List<NFTCollection>.transform(
state: NFTCollectionsStateUM,
query: String,
): ImmutableList<NFTCollectionUM> = mapNotNull {
if (query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true) {
NFTCollectionUM(
id = it.id.toString(),
networkIconId = getActiveIconRes(it.network.id.value),
name = it.name.orEmpty(),
description = TextReference.PluralRes(
R.plurals.nft_collections_count,
it.count,
wrappedList(it.count),
),
logoUrl = it.logoUrl,
assets = it.transformAssets(),
onExpandClick = {
onExpandCollectionClick(it)
},
isExpanded = it.isExpanded(state),
)
} else {
null
}
}.toPersistentList()
private fun transformNotifications(): ImmutableList<NFTCollectionsWarningUM> = buildList {
if (nftCollections.anyCollectionFailed()) {
add(
NFTCollectionsWarningUM(
id = "loading troubles",
config = NotificationConfig(
title = TextReference.Res(R.string.nft_collections_warning_title),
subtitle = TextReference.Res(R.string.nft_collections_warning_subtitle),
iconResId = R.drawable.ic_alert_triangle_20,
),
),
)
}
}.toPersistentList()
private fun NFTCollection.transformAssets(): NFTCollectionAssetsListUM = when (val assets = this.assets) {
is NFTCollection.Assets.Empty -> NFTCollectionAssetsListUM.Init
is NFTCollection.Assets.Loading -> NFTCollectionAssetsListUM.Loading(count)
is NFTCollection.Assets.Failed -> NFTCollectionAssetsListUM.Failed { onRetryAssetsClick(this) }
is NFTCollection.Assets.Value -> NFTCollectionAssetsListUM.Content(
items = assets
.items
.map { it.transform() }
.toPersistentList(),
)
}
private fun NFTAsset.transform(): NFTCollectionAssetUM = NFTCollectionAssetUM(
id = id.toString(),
name = name.orEmpty(),
imageUrl = media?.url,
price = when (val salePrice = salePrice) {
is NFTSalePrice.Empty -> NFTSalePriceUM.Failed
is NFTSalePrice.Loading -> NFTSalePriceUM.Loading
is NFTSalePrice.Error -> NFTSalePriceUM.Failed
is NFTSalePrice.Value -> NFTSalePriceUM.Content(salePrice.value.toString())
},
onItemClick = {
onAssetClick(this)
},
)
private fun NFTCollection.isExpanded(state: NFTCollectionsStateUM): Boolean =
(state.content as? NFTCollectionsUM.Content)
?.collections
?.firstOrNull { it.id == id.toString() }
?.isExpanded
?: false
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.utils.transformer.Transformer
internal class UpdateSearchQueryTransformer(private val newQuery: String) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when (val content = prevState.content) {
is NFTCollectionsUM.Content -> content.copy(
search = content.search.copy(
query = newQuery,
),
)
is NFTCollectionsUM.Empty,
is NFTCollectionsUM.Loading,
is NFTCollectionsUM.Failed,
-> content
},
)
}

View file

@ -0,0 +1,138 @@
package com.tangem.features.nft.collections.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.nft.FetchNFTCollectionAssetsUseCase
import com.tangem.domain.nft.GetNFTCollectionsUseCase
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.features.nft.collections.entity.*
import com.tangem.features.nft.collections.entity.transformer.ChangeCollectionExpandedStateTransformer
import com.tangem.features.nft.collections.entity.transformer.ToggleSearchBarTransformer
import com.tangem.features.nft.collections.entity.transformer.UpdateDataStateTransformer
import com.tangem.features.nft.collections.entity.transformer.UpdateSearchQueryTransformer
import com.tangem.features.nft.component.NFTCollectionsComponent
import com.tangem.features.nft.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class NFTCollectionsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val searchManager: InputManager,
private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase,
private val fetchNFTCollectionAssetsUseCase: FetchNFTCollectionAssetsUseCase,
paramsContainer: ParamsContainer,
) : Model() {
val state: StateFlow<NFTCollectionsStateUM> get() = _state
private val _state = MutableStateFlow(
value = NFTCollectionsStateUM(
onBackClick = ::navigateBack,
content = NFTCollectionsUM.Loading(::onReceiveClick),
),
)
private val params: NFTCollectionsComponent.Params = paramsContainer.require()
init {
subscribeToNFTCollections()
}
private fun subscribeToNFTCollections() {
combine(
flow = getNFTCollectionsUseCase(params.userWalletId),
flow2 = searchManager.query.distinctUntilChanged(),
) { nftCollections, query ->
_state.update {
UpdateDataStateTransformer(
nftCollections = nftCollections,
searchQuery = query,
onReceiveClick = ::onReceiveClick,
onRetryClick = ::onRetryClick,
onExpandCollectionClick = ::onExpandCollectionClick,
onRetryAssetsClick = ::onRetryAssetsClick,
onAssetClick = ::onAssetClick,
initialSearchBarFactory = ::getInitialSearchBar,
).transform(it)
}
}
.launchIn(modelScope)
}
private fun onSearchQueryChange(newQuery: String) {
modelScope.launch {
_state.update { UpdateSearchQueryTransformer(newQuery).transform(it) }
searchManager.update(newQuery)
}
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = ::onSearchQueryChange,
onActiveChange = ::toggleSearchBar,
)
private fun toggleSearchBar(isActive: Boolean) {
_state.update {
ToggleSearchBarTransformer(isActive).transform(it)
}
}
private fun onExpandCollectionClick(collection: NFTCollection) {
_state.update {
ChangeCollectionExpandedStateTransformer(
collectionId = collection.id,
onFirstExpanded = { onFirstExpanded(collection) },
).transform(it)
}
}
private fun onFirstExpanded(collection: NFTCollection) {
loadCollectionAssets(collection)
}
private fun onRetryAssetsClick(collection: NFTCollection) {
loadCollectionAssets(collection)
}
private fun onRetryClick() {
// TODO refresh all
}
@Suppress("UnusedPrivateMember")
private fun onAssetClick(asset: NFTAsset) {
// TODO move to details
}
private fun onReceiveClick() {
// TODO move to receive
}
private fun navigateBack() {
router.pop()
}
private fun loadCollectionAssets(collection: NFTCollection) {
modelScope.launch {
fetchNFTCollectionAssetsUseCase(
userWalletId = params.userWalletId,
network = collection.network,
collectionId = collection.id,
)
}
}
}

View file

@ -13,12 +13,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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.datasource.CollectionPreviewParameterProvider
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge
import com.tangem.core.ui.extensions.TextReference
@ -28,13 +31,14 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.collections.entity.NFTCollectionAssetsListUM
import com.tangem.features.nft.collections.entity.NFTCollectionUM
import com.tangem.features.nft.impl.R
import kotlinx.collections.immutable.persistentListOf
private const val CHEVRON_ROTATION_EXPANDED = 180f
private const val CHEVRON_ROTATION_COLLAPSED = 0f
@Composable
internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier) {
val isExpanded = state.assets is NFTCollectionAssetsListUM.Expanded
val isExpanded = state.isExpanded
Column(
modifier = modifier,
@ -87,8 +91,12 @@ private fun Logo(state: NFTCollectionUM) {
SubcomposeAsyncImage(
modifier = Modifier
.align(Alignment.CenterStart)
.size(TangemTheme.dimens.size36),
model = state.logoUrl,
.size(TangemTheme.dimens.size36)
.clip(TangemTheme.shapes.roundedCorners8),
model = ImageRequest.Builder(LocalContext.current)
.data(state.logoUrl)
.crossfade(true)
.build(),
loading = {
RectangleShimmer(radius = TangemTheme.dimens.radius8)
},
@ -99,6 +107,7 @@ private fun Logo(state: NFTCollectionUM) {
.background(TangemTheme.colors.field.primary),
)
},
contentScale = ContentScale.Crop,
contentDescription = null,
)
CurrencyIconTopBadge(
@ -160,7 +169,8 @@ private class NFTCollectionProvider : CollectionPreviewParameterProvider<NFTColl
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Collapsed,
assets = NFTCollectionAssetsListUM.Content(persistentListOf()),
isExpanded = false,
onExpandClick = { },
),
NFTCollectionUM(
@ -169,10 +179,11 @@ private class NFTCollectionProvider : CollectionPreviewParameterProvider<NFTColl
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Expanded.Loading(
assets = NFTCollectionAssetsListUM.Loading(
itemsCount = 3,
),
onExpandClick = { },
isExpanded = true,
),
),
)

View file

@ -2,7 +2,6 @@ package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
@ -10,11 +9,14 @@ 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.layout.ContentScale
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.datasource.CollectionPreviewParameterProvider
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH2
@ -26,13 +28,16 @@ import com.tangem.features.nft.collections.entity.NFTSalePriceUM
@Composable
internal fun NFTCollectionAsset(state: NFTCollectionAssetUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clickable { state.onItemClick() },
modifier = modifier,
) {
SubcomposeAsyncImage(
modifier = Modifier
.aspectRatio(1f),
model = state.imageUrl,
.aspectRatio(1f)
.clip(TangemTheme.shapes.roundedCornersXMedium),
model = ImageRequest.Builder(LocalContext.current)
.data(state.imageUrl)
.crossfade(true)
.build(),
loading = {
RectangleShimmer(radius = TangemTheme.dimens.radius16)
},
@ -43,6 +48,7 @@ internal fun NFTCollectionAsset(state: NFTCollectionAssetUM, modifier: Modifier
.background(TangemTheme.colors.field.primary),
)
},
contentScale = ContentScale.Crop,
contentDescription = null,
)
SpacerH12()

View file

@ -0,0 +1,49 @@
package com.tangem.features.nft.collections.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.features.nft.impl.R
@Composable
internal fun NFTCollections(state: NFTCollectionsStateUM, modifier: Modifier = Modifier) {
BackHandler(onBack = state.onBackClick)
Scaffold(
modifier = modifier,
containerColor = TangemTheme.colors.background.secondary,
topBar = {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
onBackClick = state.onBackClick,
text = stringResourceSafe(id = R.string.nft_collections_title),
iconRes = R.drawable.ic_back_24,
)
},
content = { innerPadding ->
AnimatedContent(
targetState = state.content,
contentKey = { it::class },
label = "NFT Collections",
) {
val contentModifier = Modifier
.padding(innerPadding)
.fillMaxSize()
when (val content = it) {
is NFTCollectionsUM.Content -> NFTCollectionsContent(content, contentModifier)
is NFTCollectionsUM.Empty -> NFTCollectionsEmpty(content, contentModifier)
is NFTCollectionsUM.Failed -> NFTCollectionsFailed(content, contentModifier)
is NFTCollectionsUM.Loading -> Unit
}
}
},
)
}

View file

@ -1,12 +1,16 @@
package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -24,17 +28,12 @@ import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.nft.collections.entity.*
import com.tangem.features.nft.collections.entity.NFTCollectionAssetUM
import com.tangem.features.nft.collections.entity.NFTCollectionAssetsListUM
import com.tangem.features.nft.collections.entity.NFTCollectionUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.features.nft.collections.entity.NFTSalePriceUM
import com.tangem.features.nft.impl.R
import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
internal fun NFTCollectionsContent(state: NFTCollectionsUM.Content, modifier: Modifier = Modifier) {
internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
Box(
@ -48,29 +47,34 @@ internal fun NFTCollectionsContent(state: NFTCollectionsUM.Content, modifier: Mo
),
) {
Column(
modifier = Modifier,
modifier = Modifier
.fillMaxWidth(),
) {
SearchBar(
state = state.search,
state = content.search,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
state.warnings.fastForEach {
NFTCollectionWarning(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16),
state = it,
)
content.warnings.fastForEach {
key(it.id) {
NFTCollectionWarning(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16),
state = it,
)
}
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing60,
)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.primary),
state = listState,
) {
state.collections.fastForEach { collection ->
content.collections.fastForEach { collection ->
item(key = collection.id) {
NFTCollection(
modifier = Modifier.fillMaxWidth(),
@ -78,23 +82,26 @@ internal fun NFTCollectionsContent(state: NFTCollectionsUM.Content, modifier: Mo
)
}
when (val assets = collection.assets) {
is NFTCollectionAssetsListUM.Collapsed -> Unit
is NFTCollectionAssetsListUM.Expanded.Loading -> {
is NFTCollectionAssetsListUM.Init -> Unit
is NFTCollectionAssetsListUM.Loading -> {
assetsListLoading(
collectionId = collection.id,
content = assets,
expanded = collection.isExpanded,
)
}
is NFTCollectionAssetsListUM.Expanded.Failed -> {
is NFTCollectionAssetsListUM.Failed -> {
assetsListFailed(
collectionId = collection.id,
content = assets,
expanded = collection.isExpanded,
)
}
is NFTCollectionAssetsListUM.Expanded.Content -> {
is NFTCollectionAssetsListUM.Content -> {
assetsListContent(
collectionId = collection.id,
content = assets,
expanded = collection.isExpanded,
)
}
}
@ -106,14 +113,15 @@ internal fun NFTCollectionsContent(state: NFTCollectionsUM.Content, modifier: Mo
.fillMaxWidth()
.align(Alignment.BottomCenter),
text = stringResourceSafe(R.string.nft_collections_receive),
onClick = { },
onClick = content.onReceiveClick,
)
}
}
private fun LazyListScope.assetsListLoading(
collectionId: String,
content: NFTCollectionAssetsListUM.Expanded.Loading,
content: NFTCollectionAssetsListUM.Loading,
expanded: Boolean,
) {
val itemsCount = content.itemsCount
val rowCount = (itemsCount + 1) / 2
@ -121,60 +129,71 @@ private fun LazyListScope.assetsListLoading(
item(
key = "loading_${collectionId}_$rowIndex",
) {
val paddingValues = PaddingValues(
start = TangemTheme.dimens.spacing6,
top = TangemTheme.dimens.spacing6,
end = TangemTheme.dimens.spacing6,
bottom = TangemTheme.dimens.spacing20,
)
Row(
modifier = Modifier
.padding(TangemTheme.dimens.spacing6)
.animateItem(),
) {
NFTCollectionAssetLoading(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
AnimatedVisibility(visible = expanded) {
val paddingValues = PaddingValues(
start = TangemTheme.dimens.spacing6,
top = TangemTheme.dimens.spacing6,
end = TangemTheme.dimens.spacing6,
bottom = TangemTheme.dimens.spacing20,
)
if (rowIndex == rowCount - 1 && itemsCount % 2 != 0) {
Box(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing6)
.animateContentSize(),
) {
NFTCollectionAssetLoading(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
if (rowIndex == rowCount - 1 && itemsCount % 2 != 0) {
Box(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
} else {
NFTCollectionAssetLoading(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
}
}
}
}
}
}
private fun LazyListScope.assetsListFailed(collectionId: String, content: NFTCollectionAssetsListUM.Expanded.Failed) {
private fun LazyListScope.assetsListFailed(
collectionId: String,
content: NFTCollectionAssetsListUM.Failed,
expanded: Boolean,
) {
item(
key = "failed_$collectionId",
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size142),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(
onRetryClick = content.onRetryClick,
)
AnimatedVisibility(visible = expanded) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size142)
.animateContentSize(),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(
onRetryClick = content.onRetryClick,
)
}
}
}
}
private fun LazyListScope.assetsListContent(
collectionId: String,
content: NFTCollectionAssetsListUM.Expanded.Content,
content: NFTCollectionAssetsListUM.Content,
expanded: Boolean,
) {
val items = content.items
val itemsCount = items.size
@ -185,36 +204,43 @@ private fun LazyListScope.assetsListContent(
item(
key = "content_${collectionId}_${item1.id}_${item2?.id}",
) {
val paddingValues = PaddingValues(
start = TangemTheme.dimens.spacing6,
top = TangemTheme.dimens.spacing6,
end = TangemTheme.dimens.spacing6,
bottom = TangemTheme.dimens.spacing20,
)
Row(
modifier = Modifier
.padding(TangemTheme.dimens.spacing6)
.animateItem(),
) {
NFTCollectionAsset(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
state = item1,
AnimatedVisibility(visible = expanded) {
val paddingValues = PaddingValues(
start = TangemTheme.dimens.spacing6,
top = TangemTheme.dimens.spacing6,
end = TangemTheme.dimens.spacing6,
bottom = TangemTheme.dimens.spacing20,
)
if (item2 == null) {
Box(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing6)
.animateContentSize(),
) {
NFTCollectionAsset(
modifier = Modifier
.weight(1f)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable { item1.onItemClick() }
.padding(paddingValues),
state = item2,
state = item1,
)
if (item2 == null) {
Box(
modifier = Modifier
.weight(1f)
.padding(paddingValues),
)
} else {
NFTCollectionAsset(
modifier = Modifier
.weight(1f)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable { item2.onItemClick() }
.padding(paddingValues),
state = item2,
)
}
}
}
}
@ -228,7 +254,7 @@ private fun LazyListScope.assetsListContent(
private fun Preview_NFTCollectionsContent() {
TangemThemePreview {
NFTCollectionsContent(
state = NFTCollectionsUM.Content(
content = NFTCollectionsUM.Content(
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
@ -243,7 +269,8 @@ private fun Preview_NFTCollectionsContent() {
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Collapsed,
assets = NFTCollectionAssetsListUM.Content(persistentListOf()),
isExpanded = false,
onExpandClick = { },
),
NFTCollectionUM(
@ -252,9 +279,10 @@ private fun Preview_NFTCollectionsContent() {
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Expanded.Loading(
assets = NFTCollectionAssetsListUM.Loading(
itemsCount = 1,
),
isExpanded = true,
onExpandClick = { },
),
NFTCollectionUM(
@ -263,9 +291,10 @@ private fun Preview_NFTCollectionsContent() {
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Expanded.Failed(
assets = NFTCollectionAssetsListUM.Failed(
onRetryClick = { },
),
isExpanded = true,
onExpandClick = { },
),
NFTCollectionUM(
@ -274,7 +303,7 @@ private fun Preview_NFTCollectionsContent() {
logoUrl = "",
networkIconId = R.drawable.img_eth_22,
description = TextReference.Str("3 items"),
assets = NFTCollectionAssetsListUM.Expanded.Content(
assets = NFTCollectionAssetsListUM.Content(
items = persistentListOf(
NFTCollectionAssetUM(
id = "item1",
@ -299,11 +328,13 @@ private fun Preview_NFTCollectionsContent() {
),
),
),
isExpanded = true,
onExpandClick = { },
),
),
warnings = persistentListOf(
NFTCollectionsWarningUM(
id = "loading troubles",
config = NotificationConfig(
title = TextReference.Res(R.string.nft_collections_warning_title),
subtitle = TextReference.Res(R.string.nft_collections_warning_subtitle),

View file

@ -34,7 +34,7 @@ internal fun NFTCollectionsFailed(state: NFTCollectionsUM.Failed, modifier: Modi
.fillMaxWidth()
.align(Alignment.BottomCenter),
text = stringResourceSafe(R.string.nft_collections_receive),
onClick = { },
onClick = state.onReceiveClick,
)
}
}

View file

@ -24,6 +24,7 @@ dependencies {
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.utils)
/** Project - Common */
implementation(projects.common.routing)

View file

@ -10,6 +10,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.demo.IsDemoCardUseCase
@ -25,7 +26,6 @@ import com.tangem.features.onramp.main.entity.*
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory
import com.tangem.features.onramp.providers.entity.SelectProviderResult
import com.tangem.features.onramp.utils.InputManager
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider

View file

@ -4,6 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.onramp.FetchOnrampCountriesUseCase
@ -20,7 +21,6 @@ import com.tangem.features.onramp.selectcountry.entity.CountryListUMController
import com.tangem.features.onramp.selectcountry.entity.transformer.UpdateCountryItemsErrorTransformer
import com.tangem.features.onramp.selectcountry.entity.transformer.UpdateCountryItemsLoadingTransformer
import com.tangem.features.onramp.selectcountry.entity.transformer.UpdateCountryItemsTransformer
import com.tangem.features.onramp.utils.InputManager
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
import com.tangem.features.onramp.utils.sendOnrampErrorEvent

View file

@ -4,6 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.onramp.FetchOnrampCurrenciesUseCase
@ -20,7 +21,6 @@ import com.tangem.features.onramp.selectcurrency.entity.CurrencyListController
import com.tangem.features.onramp.selectcurrency.entity.transformer.UpdateCurrencyItemsErrorTransformer
import com.tangem.features.onramp.selectcurrency.entity.transformer.UpdateCurrencyItemsLoadingTransformer
import com.tangem.features.onramp.selectcurrency.entity.transformer.UpdateCurrencyItemsTransformer
import com.tangem.features.onramp.utils.InputManager
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
import com.tangem.features.onramp.utils.sendOnrampErrorEvent

View file

@ -3,6 +3,7 @@ package com.tangem.features.onramp.swap.availablepairs.model
import arrow.core.getOrElse
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.extensions.capitalize
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
@ -31,7 +32,6 @@ import com.tangem.features.onramp.tokenlist.entity.TokenListUMController
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer
import com.tangem.features.onramp.utils.InputManager
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
@ -63,7 +63,7 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val availablePairsByNetworkFlow = MutableStateFlow<Map<LeastTokenInfo, AvailablePairsState>>(emptyMap())
init {
initializeSearchBardCallbacks()
initializeSearchBarCallbacks()
subscribeOnUpdateState()
subscribeOnAvailablePairsUpdates()
@ -82,7 +82,7 @@ internal class AvailableSwapPairsModel @Inject constructor(
.shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1)
}
private fun initializeSearchBardCallbacks() {
private fun initializeSearchBarCallbacks() {
tokenListUMController.update(
transformer = UpdateSearchBarCallbacksTransformer(
onQueryChange = ::onSearchQueryChange,

View file

@ -4,6 +4,7 @@ import arrow.core.getOrElse
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
@ -28,7 +29,6 @@ import com.tangem.features.onramp.tokenlist.entity.TokenListUMController
import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer
import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer
import com.tangem.features.onramp.utils.InputManager
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer

View file

@ -12,6 +12,7 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.TokenActionsState
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
@ -55,6 +56,8 @@ internal interface WalletContentClickIntents {
fun onConfirmDisposeExpressStatus()
fun onDisposeExpressStatus()
fun onNFTClick(userWalletId: UserWalletId)
}
@Suppress("LongParameterList")
@ -235,4 +238,8 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
}
stateHolder.update(CloseBottomSheetTransformer(userWalletId))
}
override fun onNFTClick(userWalletId: UserWalletId) {
router.openNFTCollectionsScreen(userWalletId)
}
}

View file

@ -125,6 +125,7 @@ internal object WalletScreenPreviewData {
collectionsCount = 1,
assetsCount = 3,
isFlickering = false,
onItemClick = { },
),
)
}

View file

@ -115,4 +115,8 @@ internal class DefaultWalletRouter @Inject constructor(
override fun openScanFailedDialog(onTryAgain: () -> Unit) {
reduxStateHolder.dispatchDialogShow(StateDialog.ScanFailsDialog(StateDialog.ScanFailsSource.MAIN, onTryAgain))
}
override fun openNFTCollectionsScreen(userWalletId: UserWalletId) {
router.push(AppRoute.NFTCollections(userWalletId))
}
}

View file

@ -56,4 +56,7 @@ internal interface InnerWalletRouter {
/** Open scan failed dialog */
fun openScanFailedDialog(onTryAgain: () -> Unit)
/** Open NFT collections screen */
fun openNFTCollectionsScreen(userWalletId: UserWalletId)
}

View file

@ -10,7 +10,9 @@ sealed class WalletNFTItemUM {
data object Loading : WalletNFTItemUM()
data object Empty : WalletNFTItemUM()
data class Empty(
val onItemClick: () -> Unit,
) : WalletNFTItemUM()
data object Failed : WalletNFTItemUM()
@ -19,6 +21,7 @@ sealed class WalletNFTItemUM {
val collectionsCount: Int,
val assetsCount: Int,
val isFlickering: Boolean,
val onItemClick: () -> Unit,
) : WalletNFTItemUM() {
@Immutable

View file

@ -1,7 +1,7 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.domain.models.StatusSource
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.models.*
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNFTItemUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
@ -10,16 +10,21 @@ import kotlinx.collections.immutable.toPersistentList
internal class SetNFTCollectionsTransformer(
userWalletId: UserWalletId,
private val nftCollections: List<NFTCollections>,
private val onItemClick: () -> Unit,
) : WalletStateTransformer(userWalletId) {
override fun transform(prevState: WalletState): WalletState = when (prevState) {
is WalletState.MultiCurrency.Content -> prevState.copy(
nftState = when {
allCollectionsFailed() -> WalletNFTItemUM.Failed
anyCollectionFailed() && allLoadedCollectionsEmpty() -> WalletNFTItemUM.Failed
allCollectionsLoaded() && allCollectionsEmpty() -> WalletNFTItemUM.Empty
!allCollectionsLoaded() && allCollectionsEmpty() -> WalletNFTItemUM.Loading
else -> createContentNFTItemUM()
nftCollections.allCollectionsFailed() ->
WalletNFTItemUM.Failed
nftCollections.anyCollectionFailed() && nftCollections.allLoadedCollectionsEmpty() ->
WalletNFTItemUM.Failed
nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() ->
WalletNFTItemUM.Empty(onItemClick)
!nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() ->
WalletNFTItemUM.Loading
else -> createContentNFTItemUM(onItemClick)
},
)
is WalletState.SingleCurrency.Content,
@ -31,7 +36,7 @@ internal class SetNFTCollectionsTransformer(
-> prevState
}
private fun createContentNFTItemUM(): WalletNFTItemUM.Content {
private fun createContentNFTItemUM(onItemClick: () -> Unit): WalletNFTItemUM.Content {
val collectionsContent = nftCollections
.map { it.content }
.filterIsInstance<NFTCollections.Content.Collections>()
@ -61,34 +66,10 @@ internal class SetNFTCollectionsTransformer(
assetsCount = collections
.sumOf { it.count },
isFlickering = isFlickering,
onItemClick = onItemClick,
)
}
private fun allCollectionsFailed() = nftCollections.all {
it.content is NFTCollections.Content.Error
}
private fun anyCollectionFailed() = nftCollections.any {
it.content is NFTCollections.Content.Error
}
private fun allLoadedCollectionsEmpty() = nftCollections
.map { it.content }
.filterIsInstance<NFTCollections.Content.Collections>()
.all { it.collections.isNullOrEmpty() }
private fun allCollectionsLoaded() = nftCollections.all {
val content = it.content
content is NFTCollections.Content.Collections &&
content.source != StatusSource.CACHE
}
private fun allCollectionsEmpty() = nftCollections.all {
val content = it.content
content is NFTCollections.Content.Collections &&
content.collections.isNullOrEmpty()
}
companion object {
private const val NFT_COLLECTIONS_MAX_PREVIEWS_COUNT = 4
}

View file

@ -11,13 +11,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
@Suppress("UnusedPrivateMember")
internal class WalletNFTListSubscriber(
private val userWallet: UserWallet,
private val stateHolder: WalletStateController,
private val walletsRepository: WalletsRepository,
private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase,
clickIntents: WalletClickIntents,
private val clickIntents: WalletClickIntents,
) : WalletSubscriber() {
@OptIn(ExperimentalCoroutinesApi::class)
@ -27,8 +26,7 @@ internal class WalletNFTListSubscriber(
.flatMapLatest { nftEnabled ->
// if NFT is enabled for this wallet, then start observing changes from store and apply transformer if need
if (nftEnabled) {
getNFTCollectionsUseCase
.launch(userWallet.walletId)
getNFTCollectionsUseCase(userWallet.walletId)
.shareIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(),
@ -36,7 +34,11 @@ internal class WalletNFTListSubscriber(
)
.onEach {
stateHolder.update(
SetNFTCollectionsTransformer(userWallet.walletId, it),
SetNFTCollectionsTransformer(
userWalletId = userWallet.walletId,
nftCollections = it,
onItemClick = { clickIntents.onNFTClick(userWallet.walletId) },
),
)
}
} else {

View file

@ -713,7 +713,6 @@ internal fun LazyListScope.nftCollections(state: WalletState, itemModifier: Modi
nftCollections(
modifier = itemModifier,
state = it.nftState,
onClick = {},
)
}
}

View file

@ -34,19 +34,19 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun WalletNFTItem(state: WalletNFTItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = { }) {
internal fun WalletNFTItem(state: WalletNFTItemUM, modifier: Modifier = Modifier) {
when (state) {
is WalletNFTItemUM.Hidden -> Unit
is WalletNFTItemUM.Empty -> WalletNFTItemEmpty(
modifier = modifier,
onClick = onClick,
onClick = state.onItemClick,
)
is WalletNFTItemUM.Failed -> WalletNFTItemFailed(modifier = modifier)
is WalletNFTItemUM.Loading -> WalletNFTItemLoading(modifier = modifier)
is WalletNFTItemUM.Content -> WalletNFTItemContent(
state = state,
onClick = onClick,
onClick = state.onItemClick,
modifier = modifier,
)
}
@ -396,16 +396,15 @@ private fun RowContentContainer(
@Composable
private fun Preview_WalletNFTItem(@PreviewParameter(WalletNFTItemProvider::class) state: WalletNFTItemUM) {
TangemThemePreview {
WalletNFTItem(
state = state,
onClick = {},
)
WalletNFTItem(state = state)
}
}
private class WalletNFTItemProvider : CollectionPreviewParameterProvider<WalletNFTItemUM>(
collection = listOf(
WalletNFTItemUM.Empty,
WalletNFTItemUM.Empty(
onItemClick = { },
),
WalletNFTItemUM.Loading,
WalletNFTItemUM.Failed,
WalletNFTItemUM.Content(
@ -415,6 +414,7 @@ private class WalletNFTItemProvider : CollectionPreviewParameterProvider<WalletN
assetsCount = 125,
collectionsCount = 11,
isFlickering = true,
onItemClick = { },
),
WalletNFTItemUM.Content(
previews = persistentListOf(
@ -424,6 +424,7 @@ private class WalletNFTItemProvider : CollectionPreviewParameterProvider<WalletN
assetsCount = 125,
collectionsCount = 11,
isFlickering = false,
onItemClick = { },
),
WalletNFTItemUM.Content(
previews = persistentListOf(
@ -434,6 +435,7 @@ private class WalletNFTItemProvider : CollectionPreviewParameterProvider<WalletN
assetsCount = 125,
collectionsCount = 11,
isFlickering = false,
onItemClick = { },
),
WalletNFTItemUM.Content(
previews = persistentListOf(
@ -445,6 +447,7 @@ private class WalletNFTItemProvider : CollectionPreviewParameterProvider<WalletN
assetsCount = 125,
collectionsCount = 11,
isFlickering = false,
onItemClick = { },
),
WalletNFTItemUM.Content(
previews = persistentListOf(
@ -456,6 +459,7 @@ private class WalletNFTItemProvider : CollectionPreviewParameterProvider<WalletN
assetsCount = 125,
collectionsCount = 11,
isFlickering = true,
onItemClick = { },
),
),
)

View file

@ -7,12 +7,11 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.WalletNFTItem
private const val NFT_COLLECTIONS_CONTENT_TYPE = "NFTCollections"
internal fun LazyListScope.nftCollections(state: WalletNFTItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
internal fun LazyListScope.nftCollections(state: WalletNFTItemUM, modifier: Modifier = Modifier) {
item(key = NFT_COLLECTIONS_CONTENT_TYPE, contentType = NFT_COLLECTIONS_CONTENT_TYPE) {
WalletNFTItem(
modifier = modifier,
state = state,
onClick = onClick,
)
}
}