diff --git a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt index 039fa951ff..f9972a842d 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -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, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 6fbc0a8a83..b68f419f3f 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -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 } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 6619d29662..60e5d50f16 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -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}") } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt index 753597e44a..cf693aab39 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt @@ -42,7 +42,7 @@ internal class DefaultNFTRuntimeStore( ) } - override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow = + override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow = 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 = 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): 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, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt index e9b27f5d0c..8c211d1f8e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt @@ -16,7 +16,9 @@ interface NFTRuntimeStore { fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow - fun getSalePrice(assetId: NFTAsset.Identifier): Flow + fun getSalePrice(assetId: NFTAsset.Identifier): Flow + + suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice suspend fun saveCollections(collections: NFTCollections) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt index add09e2c0a..db95b8ce76 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt @@ -12,7 +12,7 @@ object NFTSdkAssetConverter : Converter, 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, 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, ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt index ed9a5ed3ec..603e003ccb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt @@ -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, NF } .filter { it.id !is NFTAsset.Identifier.Unknown + } + .let { + if (it.isEmpty()) { + NFTCollection.Assets.Empty + } else { + NFTCollection.Assets.Value( + items = it, + source = StatusSource.CACHE, + ) + } }, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/InputManager.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/InputManager.kt similarity index 88% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/InputManager.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/fields/InputManager.kt index b3add7d7ee..42e959887b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/InputManager.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/InputManager.kt @@ -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 get() = _query diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index 5a89fbe57f..ebf03a5f42 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -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() + private val networkJobs = ConcurrentHashMap() + private val collectionJobs = ConcurrentHashMap() - private val nftRuntimeStores = mutableMapOf() - private val nftPersistenceStores = mutableMapOf() + private val nftRuntimeStores = ConcurrentHashMap() + private val nftPersistenceStores = ConcurrentHashMap() override fun observeCollections(userWalletId: UserWalletId, networks: List): Flow> = 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.mergeWithStoredAssets( userWalletId: UserWalletId, network: Network, diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt index 5dc33bd1cd..d15b9f68bd 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -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 diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt index e71d19ab0f..b40c497186 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -259,13 +259,20 @@ interface WalletManagersFacade { collectionIdentifier: NFTCollection.Identifier, ): List - 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 diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt index f2cbe202c4..761db519d4 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt @@ -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, + 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, - 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( diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt index 55563ea813..58b5aea439 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt @@ -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 = 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, + val source: StatusSource, + ) : Assets() + } + sealed class Identifier { data class EVM(val tokenAddress: String) : Identifier() data class TON(val contractAddress: String?) : Identifier() diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt index bd97bf4cfe..42eb1cc51e 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt @@ -27,4 +27,31 @@ data class NFTCollections( ), ) } +} + +fun List.allCollectionsFailed() = this.all { + it.content is NFTCollections.Content.Error +} + +fun List.anyCollectionFailed() = this.any { + it.content is NFTCollections.Content.Error || + it.content is NFTCollections.Content.Collections && + it.content.source == StatusSource.ONLY_CACHE +} + +fun List.allLoadedCollectionsEmpty() = this + .map { it.content } + .filterIsInstance() + .all { it.collections.isNullOrEmpty() } + +fun List.allCollectionsLoaded() = this.all { + val content = it.content + content is NFTCollections.Content.Collections && + content.source != StatusSource.CACHE +} + +fun List.allCollectionsEmpty() = this.all { + val content = it.content + content is NFTCollections.Content.Collections && + content.collections.isNullOrEmpty() } \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt index 3ef25c436e..26b9c2e17f 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt @@ -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() } \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionAssetsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionAssetsUseCase.kt new file mode 100644 index 0000000000..66d5f1c780 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionAssetsUseCase.kt @@ -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) + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt index f59d8578b6..6619b45d46 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt @@ -14,7 +14,7 @@ class GetNFTCollectionsUseCase( ) { @OptIn(ExperimentalCoroutinesApi::class) - fun launch(userWalletId: UserWalletId): Flow> = currenciesRepository + operator fun invoke(userWalletId: UserWalletId): Flow> = currenciesRepository .getWalletCurrenciesUpdates(userWalletId) .flatMapLatest { val networks = it diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt index 9a220eb202..7ab39c2bff 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt @@ -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): Flow> suspend fun refreshCollections(userWalletId: UserWalletId, networks: List) + + suspend fun refreshAssets(userWalletId: UserWalletId, network: Network, collectionId: NFTCollection.Identifier) } \ No newline at end of file diff --git a/features/nft/api/build.gradle.kts b/features/nft/api/build.gradle.kts index f94a0582c0..eb681efb3a 100644 --- a/features/nft/api/build.gradle.kts +++ b/features/nft/api/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { /* Project - Domain */ implementation(projects.domain.models) + implementation(projects.domain.wallets.models) /* Project - Core */ implementation(projects.core.decompose) diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt new file mode 100644 index 0000000000..3417b6a1f2 --- /dev/null +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt @@ -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 +} \ No newline at end of file diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index 7c051fa34e..a33b3e1ad5 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -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) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt index b456b29b0b..09b06ecd70 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt @@ -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 } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt new file mode 100644 index 0000000000..bb82591e48 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetsListUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetsListUM.kt index 7b0147b03a..742ae7adce 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetsListUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetsListUM.kt @@ -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) : 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) : NFTCollectionAssetsListUM() } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt index 8e3375906b..e7e24ded3b 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt @@ -10,5 +10,6 @@ internal data class NFTCollectionUM( val logoUrl: String?, val description: TextReference, val assets: NFTCollectionAssetsListUM, + val isExpanded: Boolean, val onExpandClick: () -> Unit, ) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt new file mode 100644 index 0000000000..14267e3120 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt @@ -0,0 +1,6 @@ +package com.tangem.features.nft.collections.entity + +internal data class NFTCollectionsStateUM( + val onBackClick: () -> Unit, + val content: NFTCollectionsUM, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsWarningUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsWarningUM.kt index f0bdc22aaa..c2f3e9ab11 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsWarningUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsWarningUM.kt @@ -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) \ No newline at end of file +internal data class NFTCollectionsWarningUM( + val id: String, + val config: NotificationConfig, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt new file mode 100644 index 0000000000..867c3d2303 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt @@ -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 { + + 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(), + ) + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ToggleSearchBarTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ToggleSearchBarTransformer.kt new file mode 100644 index 0000000000..12445f43ce --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ToggleSearchBarTransformer.kt @@ -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 { + + 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 + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt new file mode 100644 index 0000000000..e5fe0846c9 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -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, + 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 { + + 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() + .map { it.collections.orEmpty().transform(prevState, searchQuery) } + .flatten() + .toPersistentList(), + warnings = transformNotifications(), + onReceiveClick = onReceiveClick, + ) + } + }, + ) + + private fun List.transform( + state: NFTCollectionsStateUM, + query: String, + ): ImmutableList = 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 = 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 +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateSearchQueryTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateSearchQueryTransformer.kt new file mode 100644 index 0000000000..e2dd4173fa --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateSearchQueryTransformer.kt @@ -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 { + + 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 + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt new file mode 100644 index 0000000000..5a206e8c95 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt @@ -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 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, + ) + } + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt index 32f7159f78..ffaf0c94b1 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt @@ -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 + 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 + } + } + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt index 6a1b002cf4..e1301e1146 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt @@ -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), diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt index f16d193537..d4d99bc548 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt @@ -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, ) } } diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 2df9a81756..b32858e319 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -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) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 63a9b5ce7e..3b86caa30d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -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 diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt index e7a8f8dedf..8d0835efff 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt @@ -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 diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt index 93d64b7bca..1162910ed0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt @@ -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 diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index d31bb7513d..09f41bc29d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -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>(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, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index 76809b1485..666236c87f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -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 diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 90fdd5b760..79fd4f1a7f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -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) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index b8cf556b05..08937391da 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -125,6 +125,7 @@ internal object WalletScreenPreviewData { collectionsCount = 1, assetsCount = 3, isFlickering = false, + onItemClick = { }, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 05a688f302..f6305b826f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -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)) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 9ec77cb66c..4e49fb9f70 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -56,4 +56,7 @@ internal interface InnerWalletRouter { /** Open scan failed dialog */ fun openScanFailedDialog(onTryAgain: () -> Unit) + + /** Open NFT collections screen */ + fun openNFTCollectionsScreen(userWalletId: UserWalletId) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNFTItemUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNFTItemUM.kt index 1d2dd2b8ba..f4406d5dc5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNFTItemUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNFTItemUM.kt @@ -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 diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt index f713b64077..9c5fcb1479 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt @@ -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, + 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() @@ -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() - .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 } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt index 476770ce55..67a36a2935 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt @@ -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 { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 28aa1a4a5e..4dcbc3b0a9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -713,7 +713,6 @@ internal fun LazyListScope.nftCollections(state: WalletState, itemModifier: Modi nftCollections( modifier = itemModifier, state = it.nftState, - onClick = {}, ) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletNFTItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletNFTItem.kt index 7f2a8515b7..079f2876e1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletNFTItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletNFTItem.kt @@ -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( collection = listOf( - WalletNFTItemUM.Empty, + WalletNFTItemUM.Empty( + onItemClick = { }, + ), WalletNFTItemUM.Loading, WalletNFTItemUM.Failed, WalletNFTItemUM.Content( @@ -415,6 +414,7 @@ private class WalletNFTItemProvider : CollectionPreviewParameterProvider 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, ) } } \ No newline at end of file