Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-23 14:31:44 +05:00
parent c300a5eaf3
commit d99c4e1405
15 changed files with 316 additions and 56 deletions

View file

@ -35,6 +35,16 @@ internal object NFTDomainModule {
nftRepository = nftRepository,
)
@Provides
@Singleton
fun providesRefreshAllUseCase(
currenciesRepository: CurrenciesRepository,
nftRepository: NFTRepository,
): RefreshAllNFTUseCase = RefreshAllNFTUseCase(
currenciesRepository = currenciesRepository,
nftRepository = nftRepository,
)
@Provides
@Singleton
fun providesFetchNFTCollectionAssetsUseCase(nftRepository: NFTRepository): FetchNFTCollectionAssetsUseCase =

View file

@ -41,6 +41,7 @@ fun SearchBar(
state: SearchBarUM,
modifier: Modifier = Modifier,
colors: TextFieldColors = TangemSearchBarDefaults.defaultTextFieldColors,
enabled: Boolean = true,
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
@ -57,6 +58,7 @@ fun SearchBar(
state.onActiveChange(false)
}
},
enabled = enabled,
value = state.query,
onValueChange = state.onQueryChange,
interactionSource = interactionSource,

View file

@ -64,38 +64,11 @@ internal class DefaultNFTRepository @Inject constructor(
flowOf(NFTCollections.empty(network))
}
override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List<Network>) = coroutineScope {
networks.mapNotNull { network ->
if (network.canHandleNFTs()) {
launch(dispatchers.io) {
Either.catch {
expireCollections(userWalletId, network)
override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List<Network>) =
refreshCollectionsInternal(userWalletId, networks, refreshAssets = false)
val collections = walletManagersFacade.getNFTCollections(userWalletId, network)
val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network)
saveCollectionsInRuntime(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
}.onLeft {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
}
}.saveIn(getNetworkJobHolder(network))
} else {
null
}
}.joinAll()
override suspend fun refreshAll(userWalletId: UserWalletId, networks: List<Network>) {
refreshCollectionsInternal(userWalletId, networks, refreshAssets = true)
}
override suspend fun refreshAssets(
@ -162,6 +135,54 @@ internal class DefaultNFTRepository @Inject constructor(
assetIdentifier = NFTSdkAssetIdentifierConverter.convertBack(assetIdentifier),
)
private suspend fun refreshCollectionsInternal(
userWalletId: UserWalletId,
networks: List<Network>,
refreshAssets: Boolean,
) = coroutineScope {
networks.mapNotNull { network ->
if (network.canHandleNFTs()) {
launch(dispatchers.io) {
Either.catch {
expireCollections(userWalletId, network)
val collections = walletManagersFacade.getNFTCollections(userWalletId, network)
val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network)
saveCollectionsInRuntime(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
if (refreshAssets) {
mergedCollections.forEach { collection ->
refreshAssets(
userWalletId = userWalletId,
network = network,
collectionId = NFTSdkCollectionIdentifierConverter.convert(collection.identifier),
)
}
}
}.onLeft {
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
error = it,
)
}
}.saveIn(getNetworkJobHolder(network))
} else {
null
}
}.joinAll()
}
private suspend fun refreshSalePrice(
userWalletId: UserWalletId,
network: Network,

View file

@ -0,0 +1,16 @@
package com.tangem.domain.nft
import com.tangem.domain.nft.repository.NFTRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.models.UserWalletId
class RefreshAllNFTUseCase(
private val currenciesRepository: CurrenciesRepository,
private val nftRepository: NFTRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId) {
val currencies = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId)
nftRepository.refreshAll(userWalletId, currencies.map { it.network }.distinct())
}
}

View file

@ -14,6 +14,8 @@ interface NFTRepository {
suspend fun refreshAssets(userWalletId: UserWalletId, network: Network, collectionId: NFTCollection.Identifier)
suspend fun refreshAll(userWalletId: UserWalletId, networks: List<Network>)
suspend fun isNFTSupported(network: Network): Boolean
suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String?

View file

@ -1,6 +1,9 @@
package com.tangem.features.nft.collections.entity
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
internal data class NFTCollectionsStateUM(
val onBackClick: () -> Unit,
val pullToRefreshConfig: PullToRefreshConfig,
val content: NFTCollectionsUM,
)

View file

@ -12,6 +12,7 @@ internal sealed class NFTCollectionsUM {
) : NFTCollectionsUM()
data class Loading(
val search: SearchBarUM,
val onReceiveClick: () -> Unit,
) : NFTCollectionsUM()

View file

@ -0,0 +1,15 @@
package com.tangem.features.nft.collections.entity.transformer
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.utils.transformer.Transformer
internal class ChangeRefreshingStateTransformer(
private val isRefreshing: Boolean,
) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
pullToRefreshConfig = prevState.pullToRefreshConfig.copy(
isRefreshing = isRefreshing,
),
)
}

View file

@ -4,6 +4,7 @@ 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.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.nft.models.*
import com.tangem.features.nft.collections.entity.*
@ -24,8 +25,8 @@ internal class UpdateDataStateTransformer(
private val initialSearchBarFactory: () -> SearchBarUM,
) : Transformer<NFTCollectionsStateUM> {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy(
content = when {
override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM {
val content = when {
nftCollections.allCollectionsFailed() ->
NFTCollectionsUM.Failed(onRetryClick, onReceiveClick)
nftCollections.anyCollectionFailed() && nftCollections.allLoadedCollectionsEmpty() ->
@ -33,7 +34,16 @@ internal class UpdateDataStateTransformer(
nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() ->
NFTCollectionsUM.Empty(onReceiveClick)
!nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() ->
NFTCollectionsUM.Loading(onReceiveClick)
NFTCollectionsUM.Loading(
onReceiveClick = onReceiveClick,
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = { },
onActiveChange = { },
),
)
else -> {
NFTCollectionsUM.Content(
search = if (prevState.content is NFTCollectionsUM.Content) {
@ -54,8 +64,17 @@ internal class UpdateDataStateTransformer(
onReceiveClick = onReceiveClick,
)
}
},
)
}
return prevState.copy(
content = content,
pullToRefreshConfig = when (content) {
is NFTCollectionsUM.Loading -> prevState.pullToRefreshConfig.copy(
isRefreshing = false,
)
else -> prevState.pullToRefreshConfig
},
)
}
private fun List<NFTCollection>.transform(
state: NFTCollectionsStateUM,

View file

@ -3,15 +3,18 @@ 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.ui.components.containers.pullToRefresh.PullToRefreshConfig
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.RefreshAllNFTUseCase
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.features.nft.collections.NFTCollectionsComponent
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
import com.tangem.features.nft.collections.entity.NFTCollectionsUM
import com.tangem.features.nft.collections.entity.transformer.*
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
@ -29,6 +32,7 @@ internal class NFTCollectionsModel @Inject constructor(
private val searchManager: InputManager,
private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase,
private val fetchNFTCollectionAssetsUseCase: FetchNFTCollectionAssetsUseCase,
private val refreshAllNFTUseCase: RefreshAllNFTUseCase,
paramsContainer: ParamsContainer,
) : Model() {
@ -37,7 +41,20 @@ internal class NFTCollectionsModel @Inject constructor(
private val _state = MutableStateFlow(
value = NFTCollectionsStateUM(
onBackClick = params.onBackClick,
content = NFTCollectionsUM.Loading(params.onBackClick),
content = NFTCollectionsUM.Loading(
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = { },
onActiveChange = { },
),
onReceiveClick = params.onReceiveClick,
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = { onRefresh() },
),
),
)
@ -59,7 +76,7 @@ internal class NFTCollectionsModel @Inject constructor(
onReceiveClick = {
params.onReceiveClick()
},
onRetryClick = ::onRetryClick,
onRetryClick = ::onRefresh,
onExpandCollectionClick = ::onExpandCollectionClick,
onRetryAssetsClick = ::onRetryAssetsClick,
onAssetClick = { asset, collectionName ->
@ -69,9 +86,21 @@ internal class NFTCollectionsModel @Inject constructor(
).transform(it)
}
}
.onStart { onRefresh() }
.launchIn(modelScope)
}
private fun onRefresh() {
modelScope.launch {
_state.update { ChangeRefreshingStateTransformer(true).transform(it) }
try {
refreshAllNFTUseCase(params.userWalletId)
} finally {
_state.update { ChangeRefreshingStateTransformer(false).transform(it) }
}
}
}
private fun onSearchQueryChange(newQuery: String) {
modelScope.launch {
_state.update { UpdateSearchQueryTransformer(newQuery).transform(it) }
@ -111,10 +140,6 @@ internal class NFTCollectionsModel @Inject constructor(
loadCollectionAssets(collection)
}
private fun onRetryClick() {
// TODO refresh all
}
private fun loadCollectionAssets(collection: NFTCollection) {
modelScope.launch {
fetchNFTCollectionAssetsUseCase(

View file

@ -1,12 +1,12 @@
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.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM
@ -29,19 +29,17 @@ internal fun NFTCollections(state: NFTCollectionsStateUM, modifier: Modifier = M
)
},
content = { innerPadding ->
AnimatedContent(
targetState = state.content,
contentKey = { it::class },
label = "NFT Collections",
) {
val contentModifier = Modifier
TangemPullToRefreshContainer(
config = state.pullToRefreshConfig,
modifier = 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
.fillMaxSize(),
) {
when (val content = state.content) {
is NFTCollectionsUM.Content -> NFTCollectionsContent(content)
is NFTCollectionsUM.Empty -> NFTCollectionsEmpty(content)
is NFTCollectionsUM.Failed -> NFTCollectionsFailed(content)
is NFTCollectionsUM.Loading -> NFTCollectionsLoading(content)
}
}
},

View file

@ -3,6 +3,8 @@ package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -23,6 +25,7 @@ internal fun NFTCollectionsEmpty(state: NFTCollectionsUM.Empty, modifier: Modifi
Box(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(TangemTheme.dimens.spacing16),
) {
Column(

View file

@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -22,6 +24,7 @@ internal fun NFTCollectionsFailed(state: NFTCollectionsUM.Failed, modifier: Modi
Box(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(TangemTheme.dimens.spacing16),
) {
UnableToLoadData(

View file

@ -0,0 +1,142 @@
package com.tangem.features.nft.collections.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
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.NFTCollectionsUM
import com.tangem.features.nft.impl.R
private const val SHIMMER_ITEMS_COUNT = 5
@Composable
internal fun NFTCollectionsLoading(state: NFTCollectionsUM.Loading, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(TangemTheme.dimens.spacing16),
) {
Column {
SearchBar(
state = state.search,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
enabled = false,
)
Card(
modifier = modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing16,
),
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
colors = CardDefaults.cardColors(
containerColor = TangemTheme.colors.background.primary,
contentColor = TangemTheme.colors.text.primary1,
disabledContainerColor = TangemTheme.colors.background.primary,
disabledContentColor = TangemTheme.colors.text.primary1,
),
) {
Column {
repeat(SHIMMER_ITEMS_COUNT) {
CollectionPlaceholder()
}
}
}
}
PrimaryButton(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
text = stringResourceSafe(R.string.nft_collections_receive),
onClick = state.onReceiveClick,
)
}
}
@Composable
private fun CollectionPlaceholder(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing16,
),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(TangemTheme.dimens.size36)
.clip(RoundedCornerShape(TangemTheme.dimens.radius8))
.background(TangemTheme.colors.field.primary),
)
}
Column(
modifier = Modifier
.padding(
horizontal = TangemTheme.dimens.spacing12,
)
.weight(1f),
horizontalAlignment = Alignment.Start,
) {
TextShimmer(
modifier = Modifier.width(TangemTheme.dimens.size110),
style = TangemTheme.typography.caption2,
textSizeHeight = true,
)
TextShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size80)
.padding(top = TangemTheme.dimens.spacing4),
style = TangemTheme.typography.caption2,
textSizeHeight = true,
)
}
}
}
}
@Preview(widthDp = 360, showBackground = true)
@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NFTCollectionsLoading() {
TangemThemePreview {
NFTCollectionsLoading(
state = NFTCollectionsUM.Loading(
search = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = { },
onActiveChange = { },
),
onReceiveClick = { },
),
)
}
}

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1041"
tangemBlockchainSdk = "develop-1042"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-455"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^