Updated on 2026-08-14

This commit is contained in:
Tangem 2025-03-18 14:32:17 +03:00
commit a51233abaf
79 changed files with 1041 additions and 412 deletions

View file

@ -3,7 +3,6 @@ package com.tangem.tap
import androidx.hilt.work.HiltWorkerFactory
import com.tangem.TangemSdkLogger
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.data.card.TransactionSignerFactory
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.routing.AppRouter
import com.tangem.core.analytics.filter.OneTimeEventFilter
@ -15,6 +14,7 @@ import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.core.navigation.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.data.card.TransactionSignerFactory
import com.tangem.datasource.connection.NetworkConnectionManager
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.datasource.local.config.issuers.IssuersConfigStorage
@ -32,13 +32,13 @@ import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
import com.tangem.tap.common.log.TangemAppLoggerInitializer
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
@ -94,8 +94,6 @@ interface ApplicationEntryPoint {
fun getSaveTwinsOnboardingShownUseCase(): SaveTwinsOnboardingShownUseCase
fun getWalletNameGenerateUseCase(): GenerateWalletNameUseCase
fun getCardRepository(): CardRepository
fun getTangemSdkLogger(): TangemSdkLogger
@ -118,8 +116,6 @@ interface ApplicationEntryPoint {
fun getTransactionSignerFactory(): TransactionSignerFactory
fun getGetUserCountryCodeUseCase(): GetUserCountryUseCase
fun getOnrampFeatureToggles(): OnrampFeatureToggles
fun getOnboardingV2FeatureToggles(): OnboardingV2FeatureToggles
@ -142,4 +138,8 @@ interface ApplicationEntryPoint {
fun getUiMessageSender(): UiMessageSender
fun getWorkerFactory(): HiltWorkerFactory
fun getOnlineCardVerifier(): OnlineCardVerifier
fun getUserWalletBuilderFactory(): UserWalletBuilder.Factory
}

View file

@ -46,13 +46,13 @@ import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler
@ -146,9 +146,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
private val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase
get() = entryPoint.getSaveTwinsOnboardingShownUseCase()
private val generateWalletNameUseCase: GenerateWalletNameUseCase
get() = entryPoint.getWalletNameGenerateUseCase()
private val cardRepository: CardRepository
get() = entryPoint.getCardRepository()
@ -182,9 +179,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
private val transactionSignerFactory: TransactionSignerFactory
get() = entryPoint.getTransactionSignerFactory()
private val getUserCountryUseCase: GetUserCountryUseCase
get() = entryPoint.getGetUserCountryCodeUseCase()
private val onrampFeatureToggles: OnrampFeatureToggles
get() = entryPoint.getOnrampFeatureToggles()
@ -220,6 +214,11 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
.setWorkerFactory(workerFactory)
.build()
private val onlineCardVerifier: OnlineCardVerifier
get() = entryPoint.getOnlineCardVerifier()
private val userWalletBuilderFactory: UserWalletBuilder.Factory
get() = entryPoint.getUserWalletBuilderFactory()
// endregion
override fun onCreate() {
@ -301,7 +300,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
generalUserWalletsListManager = generalUserWalletsListManager,
wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase,
saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase,
generateWalletNameUseCase = generateWalletNameUseCase,
cardRepository = cardRepository,
settingsRepository = settingsRepository,
blockchainSDKFactory = blockchainSDKFactory,
@ -312,7 +310,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
shareManager = shareManager,
appRouter = appRouter,
transactionSignerFactory = transactionSignerFactory,
getUserCountryUseCase = getUserCountryUseCase,
onrampFeatureToggles = onrampFeatureToggles,
environmentConfigStorage = environmentConfigStorage,
onboardingV2FeatureToggles = onboardingV2FeatureToggles,
@ -322,6 +319,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat
clipboardManager = clipboardManager,
settingsManager = settingsManager,
uiMessageSender = uiMessageSender,
onlineCardVerifier = onlineCardVerifier,
userWalletBuilderFactory = userWalletBuilderFactory,
),
),
)

View file

@ -0,0 +1,40 @@
package com.tangem.tap.di.domain
import com.tangem.domain.nft.FetchNFTCollectionsUseCase
import com.tangem.domain.nft.GetNFTCollectionsUseCase
import com.tangem.domain.nft.repository.NFTRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object NFTDomainModule {
@Provides
@Singleton
fun providesGetNFTCollectionsUseCase(
currenciesRepository: CurrenciesRepository,
nftRepository: NFTRepository,
): GetNFTCollectionsUseCase {
return GetNFTCollectionsUseCase(
currenciesRepository = currenciesRepository,
nftRepository = nftRepository,
)
}
@Provides
@Singleton
fun providesFetchNFTCollectionsUseCase(
currenciesRepository: CurrenciesRepository,
nftRepository: NFTRepository,
): FetchNFTCollectionsUseCase {
return FetchNFTCollectionsUseCase(
currenciesRepository = currenciesRepository,
nftRepository = nftRepository,
)
}
}

View file

@ -10,7 +10,10 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.*
import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.features.nft.NFTFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -18,6 +21,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@Module
@InstallIn(SingletonComponent::class)
internal object WalletsDomainModule {
@ -165,4 +169,22 @@ internal object WalletsDomainModule {
fun providesSeedPhraseNotificationUseCase(walletsRepository: WalletsRepository): SeedPhraseNotificationUseCase {
return SeedPhraseNotificationUseCase(walletsRepository = walletsRepository)
}
@Provides
@Singleton
fun provideGetCardImageUseCase(onlineCardVerifier: OnlineCardVerifier): GetCardImageUseCase {
return GetCardImageUseCase(verifier = onlineCardVerifier)
}
@Provides
@Singleton
fun providesIsWalletNFTEnabledSyncUseCase(
walletsRepository: WalletsRepository,
nftFeatureToggles: NFTFeatureToggles,
): IsWalletNFTEnabledSyncUseCase {
return IsWalletNFTEnabledSyncUseCase(
walletsRepository = walletsRepository,
nftFeatureToggles = nftFeatureToggles,
)
}
}

View file

@ -6,8 +6,8 @@ import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.getTwinCardNumber
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.wallets.models.Artwork
import com.tangem.operations.attestation.CardVerifyAndGetInfo
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.operations.attestation.api.models.CardVerifyAndGetInfo
fun CardDTO.signedHashesCount(): Int {
return wallets.sumOf { it.totalSignedHashes ?: 0 }

View file

@ -19,7 +19,6 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.tokens.TokensAction
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
import com.tangem.tap.common.analytics.events.IntroductionProcess
@ -44,12 +43,12 @@ import javax.inject.Inject
internal class HomeModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val scanCardProcessor: ScanCardProcessor,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val saveWalletUseCase: SaveWalletUseCase,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsRepository: SettingsRepository,
private val urlOpener: UrlOpener,
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
) : Model() {
private val tangemErrorHandler = TangemTangemErrorsHandler(store)
@ -100,10 +99,7 @@ internal class HomeModel @Inject constructor(
}
private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) {
val userWallet = UserWalletBuilder(
scanResponse = scanResponse,
generateWalletNameUseCase = generateWalletNameUseCase,
).build()
val userWallet = userWalletBuilderFactory.create(scanResponse = scanResponse).build()
if (userWallet == null) {
Timber.e("User wallet not created")

View file

@ -10,7 +10,6 @@ import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
import com.tangem.tap.common.analytics.events.IntroductionProcess
import com.tangem.tap.common.extensions.dispatchNavigationAction
@ -91,8 +90,9 @@ private suspend fun readCard() {
}
private fun proceedWithScanResponse(scanResponse: ScanResponse) = scope.launch {
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
val userWallet = UserWalletBuilder(scanResponse, walletNameGenerateUseCase).build().guard {
val userWalletBuilder = store.inject(DaggerGraphState::userWalletBuilderFactory).create(scanResponse)
val userWallet = userWalletBuilder.build().guard {
Timber.e("User wallet not created")
return@launch
}

View file

@ -14,7 +14,6 @@ import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter
@ -250,8 +249,9 @@ object OnboardingHelper {
hasBackupError: Boolean,
alreadyCreatedWallet: UserWallet? = null,
) {
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
val userWallet = alreadyCreatedWallet ?: UserWalletBuilder(scanResponse, walletNameGenerateUseCase)
val userWalletBuilderFactory = store.inject(DaggerGraphState::userWalletBuilderFactory)
val userWallet = alreadyCreatedWallet ?: userWalletBuilderFactory.create(scanResponse)
.hasBackupError(hasBackupError)
.backupCardsIds(backupCardsIds?.toSet())
.build()

View file

@ -7,8 +7,8 @@ import com.tangem.common.extensions.isZero
import com.tangem.common.services.Result
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.operations.attestation.CardVerifyAndGetInfo
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.operations.attestation.api.models.CardVerifyAndGetInfo
import com.tangem.tap.common.entities.ProgressState
import com.tangem.tap.common.extensions.inject
import com.tangem.tap.common.extensions.safeUpdate

View file

@ -24,7 +24,6 @@ import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_IDS
import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_PREFIX
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.Artwork
@ -782,9 +781,10 @@ private suspend fun updateWallet(
}
private suspend fun createUserWallet(scanResponse: ScanResponse, backupState: BackupState): UserWallet {
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
val userWalletBuilder = store.inject(DaggerGraphState::userWalletBuilderFactory).create(scanResponse)
return requireNotNull(
value = UserWalletBuilder(scanResponse, walletNameGenerateUseCase)
value = userWalletBuilder
.backupCardsIds(backupState.backupCardIds.toSet())
.hasBackupError(backupState.hasBackupError)
.build(),

View file

@ -6,7 +6,6 @@ import com.tangem.common.extensions.guard
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.common.analytics.events.Onboarding
@ -70,8 +69,10 @@ internal class SaveWalletMiddleware {
scope.launch {
val backupInfo = state.backupInfo ?: error("Backup info is null")
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
val userWallet = UserWalletBuilder(backupInfo.scanResponse, walletNameGenerateUseCase)
val userWalletBuilder = store.inject(DaggerGraphState::userWalletBuilderFactory)
.create(scanResponse = backupInfo.scanResponse)
val userWallet = userWalletBuilder
.backupCardsIds(state.backupInfo.backupCardsIds)
.hasBackupError(hasBackupError)
.build()

View file

@ -11,10 +11,8 @@ import com.tangem.common.routing.utils.popTo
import com.tangem.core.analytics.Analytics
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType
import com.tangem.domain.wallets.legacy.unlockIfLockable
import com.tangem.tap.backupService
@ -118,9 +116,9 @@ internal class WelcomeMiddleware {
)
scanCardInternal { scanResponse ->
val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase)
val userWallet = UserWalletBuilder(scanResponse, walletNameGenerateUseCase).build()
?: return@scanCardInternal
val userWalletBuilder = store.inject(DaggerGraphState::userWalletBuilderFactory).create(scanResponse)
val userWallet = userWalletBuilder.build() ?: return@scanCardInternal
val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager)
userWalletsListManager.save(userWallet, canOverride = true)

View file

@ -26,13 +26,13 @@ import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles
import com.tangem.features.onramp.OnrampFeatureToggles
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles
import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository
import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor
@ -56,7 +56,6 @@ data class DaggerGraphState(
val generalUserWalletsListManager: UserWalletsListManager? = null,
val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase? = null,
val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase? = null,
val generateWalletNameUseCase: GenerateWalletNameUseCase? = null,
val cardRepository: CardRepository? = null,
val settingsRepository: SettingsRepository? = null,
val blockchainSDKFactory: BlockchainSDKFactory? = null,
@ -67,7 +66,6 @@ data class DaggerGraphState(
val shareManager: ShareManager? = null,
val appRouter: AppRouter? = null,
val transactionSignerFactory: TransactionSignerFactory? = null,
val getUserCountryUseCase: GetUserCountryUseCase? = null,
val onrampFeatureToggles: OnrampFeatureToggles? = null,
val environmentConfigStorage: EnvironmentConfigStorage? = null,
val onboardingV2FeatureToggles: OnboardingV2FeatureToggles? = null,
@ -77,4 +75,6 @@ data class DaggerGraphState(
val clipboardManager: ClipboardManager? = null,
val settingsManager: SettingsManager? = null,
val uiMessageSender: UiMessageSender? = null,
val onlineCardVerifier: OnlineCardVerifier? = null,
val userWalletBuilderFactory: UserWalletBuilder.Factory? = null,
) : StateType

View file

@ -66,5 +66,9 @@
{
"name": "NOTE_REFACTORING_ENABLED",
"version": "undefined"
},
{
"name": "NEW_ATTESTATION_ENABLED",
"version": "undefined"
}
]

View file

@ -12,7 +12,7 @@ internal class DefaultNFTPersistenceStore(
private val pricesPersistenceStore: DataStore<Map<NFTAsset.Identifier, NFTAsset.SalePrice>>,
) : NFTPersistenceStore {
override fun getCollections(): Flow<List<NFTCollection>> = collectionsPersistenceStore.data
override fun getCollections(): Flow<List<NFTCollection>?> = collectionsPersistenceStore.data
override suspend fun getCollectionsSync(): List<NFTCollection>? = collectionsPersistenceStore
.data

View file

@ -5,7 +5,7 @@ import com.tangem.blockchain.nft.models.NFTCollection
import kotlinx.coroutines.flow.Flow
interface NFTPersistenceStore {
fun getCollections(): Flow<List<NFTCollection>>
fun getCollections(): Flow<List<NFTCollection>?>
suspend fun getCollectionsSync(): List<NFTCollection>?

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.local.nft
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
@ -11,10 +12,12 @@ import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.listTypes
import com.tangem.datasource.utils.mapWithCustomKeyTypes
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.lang.reflect.ParameterizedType
import javax.inject.Inject
import javax.inject.Singleton
@ -25,41 +28,51 @@ class NFTPersistenceStoreFactory @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) {
fun provide(network: Network): NFTPersistenceStore {
val networkStringIdentifier = listOfNotNull(
network.id.value,
network.derivationPath.value,
).joinToString("_") {
// remove all non-alphanumeric characters
it
.toCharArray()
.filter(Char::isLetterOrDigit)
.joinToString("")
.lowercase()
}
fun provide(userWalletId: UserWalletId, network: Network): NFTPersistenceStore {
// simplify network identifier so that is correct for a file name
// e.g. eth_m4460000 or theopennetwork_m446070
val networkStringId =
network.id.formatted() + network.derivationPath.formatted()?.let { "_$it" }.orEmpty()
// simplify user wallet id so that is correct for a file name
// e.g. 9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a
val userWalletStringId = userWalletId.formatted()
return DefaultNFTPersistenceStore(
collectionsPersistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = listTypes<NFTCollection>(),
defaultValue = emptyList(),
),
produceFile = {
context.dataStoreFile(fileName = "nft_${networkStringIdentifier}_collections")
},
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
collectionsPersistenceStore = createPersistenceStore(
// result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_eth_m4460000_collections
// result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_theopennetwork_m446070_collections
fileName = "nft_${userWalletStringId}_${networkStringId}_collections",
types = listTypes<NFTCollection>(),
defaultValue = emptyList(),
),
pricesPersistenceStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithCustomKeyTypes<NFTAsset.Identifier, NFTAsset.SalePrice>(),
defaultValue = emptyMap(),
),
produceFile = {
context.dataStoreFile(fileName = "nft_${networkStringIdentifier}_prices")
},
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
pricesPersistenceStore = createPersistenceStore(
// result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_eth_m4460000_prices
// result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_theopennetwork_m446070_prices
fileName = "nft_${userWalletStringId}_${networkStringId}_prices",
types = mapWithCustomKeyTypes<NFTAsset.Identifier, NFTAsset.SalePrice>(),
defaultValue = emptyMap(),
),
)
}
private fun <T> createPersistenceStore(fileName: String, types: ParameterizedType, defaultValue: T): DataStore<T> =
DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = types,
defaultValue = defaultValue,
),
produceFile = { context.dataStoreFile(fileName = fileName) },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
private fun Network.ID.formatted(): String = value
.filter(Char::isLetterOrDigit)
.lowercase()
private fun Network.DerivationPath.formatted(): String? = value
?.filter(Char::isLetterOrDigit)
?.lowercase()
private fun UserWalletId.formatted(): String = stringValue
.lowercase()
}

View file

@ -7,14 +7,11 @@ import com.tangem.domain.tokens.model.Network
import com.tangem.utils.converter.Converter
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
class NFTSdkAssetConverter(
private val nftSdkAssetIdentifierConverter: NFTSdkAssetIdentifierConverter,
private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter,
) : Converter<Pair<Network, SdkNFTAsset>, NFTAsset> {
object NFTSdkAssetConverter : Converter<Pair<Network, SdkNFTAsset>, NFTAsset> {
override fun convert(value: Pair<Network, SdkNFTAsset>): NFTAsset {
val (network, asset) = value
val assetId = nftSdkAssetIdentifierConverter.convert(asset.identifier)
val collectionId = nftSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier)
val assetId = NFTSdkAssetIdentifierConverter.convert(asset.identifier)
val collectionId = NFTSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier)
return NFTAsset.Value(
id = assetId,
collectionId = collectionId,

View file

@ -6,13 +6,10 @@ import com.tangem.domain.tokens.model.Network
import com.tangem.utils.converter.Converter
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
class NFTSdkCollectionConverter(
private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter,
private val nftSdkAssetConverter: NFTSdkAssetConverter,
) : Converter<Pair<Network, SdkNFTCollection>, NFTCollection> {
object NFTSdkCollectionConverter : Converter<Pair<Network, SdkNFTCollection>, NFTCollection> {
override fun convert(value: Pair<Network, SdkNFTCollection>): NFTCollection {
val (network, collection) = value
val collectionId = nftSdkCollectionIdentifierConverter.convert(collection.identifier)
val collectionId = NFTSdkCollectionIdentifierConverter.convert(collection.identifier)
return NFTCollection(
id = collectionId,
network = network,
@ -22,7 +19,7 @@ class NFTSdkCollectionConverter(
count = collection.count,
assets = collection.assets
.map { asset ->
nftSdkAssetConverter.convert(network to asset)
NFTSdkAssetConverter.convert(network to asset)
}
.filter {
it.id !is NFTAsset.Identifier.Unknown

View file

@ -1,14 +1,14 @@
package com.tangem.data.nft
import arrow.core.Either
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.datasource.local.nft.NFTPersistenceStore
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
import com.tangem.datasource.local.nft.NFTRuntimeStore
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter
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
@ -20,8 +20,10 @@ import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.*
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import javax.inject.Inject
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
@ -34,82 +36,82 @@ internal class DefaultNFTRepository @Inject constructor(
private val jobs = mutableMapOf<Network, JobHolder>()
private val nftRuntimeStores = mutableMapOf<Network, NFTRuntimeStore>()
private val nftPersistenceStores = mutableMapOf<Network, NFTPersistenceStore>()
private val nftSdkAssetConverter = NFTSdkAssetConverter(
nftSdkAssetIdentifierConverter = NFTSdkAssetIdentifierConverter,
nftSdkCollectionIdentifierConverter = NFTSdkCollectionIdentifierConverter,
)
private val collectionConverter = NFTSdkCollectionConverter(
nftSdkCollectionIdentifierConverter = NFTSdkCollectionIdentifierConverter,
nftSdkAssetConverter = nftSdkAssetConverter,
)
private val nftRuntimeStores = mutableMapOf<String, NFTRuntimeStore>()
private val nftPersistenceStores = mutableMapOf<String, NFTPersistenceStore>()
override fun observeCollections(userWalletId: UserWalletId, networks: List<Network>): Flow<List<NFTCollections>> =
flow {
emitAll(
combine(
networks.map { getNFTRuntimeStore(it).getCollections() },
) { it.asList() },
)
}.onStart {
refreshCollections(userWalletId, networks)
}
flow { emitAll(observeCollectionsInternal(userWalletId, networks)) }
private suspend fun observeCollectionsInternal(
userWalletId: UserWalletId,
networks: List<Network>,
): Flow<List<NFTCollections>> = combine(
networks.map { observeCollectionsInternal(userWalletId, it) },
) { it.asList() }
private suspend fun observeCollectionsInternal(
userWalletId: UserWalletId,
network: Network,
): Flow<NFTCollections> = if (network.canHandleNFTs()) {
getNFTRuntimeStore(userWalletId, network).getCollections()
} else {
flowOf(NFTCollections.empty(network))
}
override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List<Network>) = coroutineScope {
networks.map { network ->
launch(dispatchers.io) {
Either.catch {
expireCollections(network)
walletManagersFacade.getNFTCollections(userWalletId, network)
}.onLeft {
saveFailedStateInRuntime(
network = network,
error = it,
)
}.onRight {
val mergedCollections = it.mergeWithStoredAssets(network)
networks.mapNotNull { network ->
if (network.canHandleNFTs()) {
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)
saveCollectionsInRuntime(
network = network,
collections = mergedCollections,
)
saveCollectionsInPersistence(
network = network,
collections = mergedCollections,
)
}
}.saveIn(getJobHolder(network))
saveCollectionsInRuntime(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
}
}.saveIn(getJobHolder(network))
} else {
null
}
}.joinAll()
}
private suspend fun expireCollections(network: Network) {
val runtimeStore = getNFTRuntimeStore(network)
private suspend fun expireCollections(userWalletId: UserWalletId, network: Network) {
val runtimeStore = getNFTRuntimeStore(userWalletId, network)
val expiredCollections = runtimeStore
.getCollectionsSync()
.let { collections ->
collections.copy(
content = when (val content = collections.content) {
is NFTCollections.Content.Collections -> content.copy(
source = StatusSource.CACHE,
)
is NFTCollections.Content.Error -> content
},
)
}
.changeStatusSource(StatusSource.CACHE)
runtimeStore.saveCollections(expiredCollections)
}
private suspend fun saveCollectionsInRuntime(network: Network, collections: List<SdkNFTCollection>) {
getNFTRuntimeStore(network).saveCollections(
private suspend fun saveCollectionsInRuntime(
userWalletId: UserWalletId,
network: Network,
collections: List<SdkNFTCollection>,
) {
getNFTRuntimeStore(userWalletId, network).saveCollections(
NFTCollections(
network = network,
content = NFTCollections.Content.Collections(
collections = collections
.map { collection ->
collectionConverter.convert(network to collection)
NFTSdkCollectionConverter.convert(network to collection)
}
.filter {
it.id !is NFTCollection.Identifier.Unknown
@ -120,19 +122,30 @@ internal class DefaultNFTRepository @Inject constructor(
)
}
private suspend fun saveFailedStateInRuntime(network: Network, error: Throwable) {
getNFTRuntimeStore(network).saveCollections(
NFTCollections(
network = network,
content = NFTCollections.Content.Error(
error = error,
),
),
)
private suspend fun saveFailedStateInRuntime(userWalletId: UserWalletId, network: Network, error: Throwable) {
getNFTRuntimeStore(userWalletId, network).let { store ->
val storedCollections = store.getCollectionsSync()
val content = storedCollections.content
val updatedCollections = if (content is NFTCollections.Content.Collections && content.collections != null) {
// if there is any cached collections in store, then mark them as not actual and emit anyway
storedCollections.changeStatusSource(StatusSource.ONLY_CACHE)
} else {
// otherwise, just emit an error
NFTCollections(
network = network,
content = NFTCollections.Content.Error(error),
)
}
store.saveCollections(updatedCollections)
}
}
private suspend fun saveCollectionsInPersistence(network: Network, collections: List<SdkNFTCollection>) {
getNFTPersistenceStore(network).saveCollections(collections)
private suspend fun saveCollectionsInPersistence(
userWalletId: UserWalletId,
network: Network,
collections: List<SdkNFTCollection>,
) {
getNFTPersistenceStore(userWalletId, network).saveCollections(collections)
}
private fun getJobHolder(network: Network): JobHolder = jobs[network] ?: run {
@ -141,65 +154,85 @@ internal class DefaultNFTRepository @Inject constructor(
}
}
private fun getNFTPersistenceStore(network: Network): NFTPersistenceStore = nftPersistenceStores[network] ?: run {
nftPersistenceStoreFactory.provide(network).also {
nftPersistenceStores[network] = it
private fun getNFTPersistenceStore(userWalletId: UserWalletId, network: Network): NFTPersistenceStore {
val storeId = (userWalletId to network).formatted()
return nftPersistenceStores[storeId] ?: run {
nftPersistenceStoreFactory.provide(userWalletId, network).also {
nftPersistenceStores[storeId] = it
}
}
}
private suspend fun getNFTRuntimeStore(network: Network): NFTRuntimeStore = nftRuntimeStores[network] ?: run {
nftRuntimeStoreFactory.provide(network).also {
nftRuntimeStores[network] = it
val storedCollections = getStoredCollections(network)
val storedPrices = getStoredPrices(network)
it.initialize(
collections = storedCollections,
prices = storedPrices,
)
private suspend fun getNFTRuntimeStore(userWalletId: UserWalletId, network: Network): NFTRuntimeStore {
val storeId = (userWalletId to network).formatted()
return nftRuntimeStores[storeId] ?: run {
nftRuntimeStoreFactory.provide(network).also {
nftRuntimeStores[storeId] = it
val storedCollections = getStoredCollections(userWalletId, network)
val storedPrices = getStoredPrices(userWalletId, network)
it.initialize(
collections = storedCollections,
prices = storedPrices,
)
}
}
}
private suspend fun getStoredCollections(network: Network) = getNFTPersistenceStore(network)
.getCollectionsSync()
.let {
NFTCollections(
network = network,
content = NFTCollections.Content.Collections(
collections = it
?.map { collection ->
collectionConverter.convert(network to collection)
}
?.filter {
it.id !is NFTCollection.Identifier.Unknown
},
source = StatusSource.CACHE,
),
)
}
private suspend fun getStoredPrices(network: Network) = getNFTPersistenceStore(network)
.getSalePricesSync()
.orEmpty()
.let { prices ->
prices
.mapKeys {
val (assetId, _) = it
NFTSdkAssetIdentifierConverter.convert(assetId)
}
.mapValues {
val (assetId, price) = it
NFTSalePrice.Value(
assetId = assetId,
value = price.value,
symbol = price.symbol,
private suspend fun getStoredCollections(userWalletId: UserWalletId, network: Network) =
getNFTPersistenceStore(userWalletId, network)
.getCollectionsSync()
.let {
NFTCollections(
network = network,
content = NFTCollections.Content.Collections(
collections = it
?.map { collection ->
NFTSdkCollectionConverter.convert(network to collection)
}
?.filter {
it.id !is NFTCollection.Identifier.Unknown
},
source = StatusSource.CACHE,
)
}
}
),
)
}
private suspend fun List<SdkNFTCollection>.mergeWithStoredAssets(network: Network): List<SdkNFTCollection> {
private suspend fun getStoredPrices(userWalletId: UserWalletId, network: Network) =
getNFTPersistenceStore(userWalletId, network)
.getSalePricesSync()
.orEmpty()
.let { prices ->
prices
.mapKeys {
val (assetId, _) = it
NFTSdkAssetIdentifierConverter.convert(assetId)
}
.mapValues {
val (assetId, price) = it
NFTSalePrice.Value(
assetId = assetId,
value = price.value,
symbol = price.symbol,
source = StatusSource.CACHE,
)
}
}
private fun NFTCollections.changeStatusSource(source: StatusSource) = copy(
content = when (val content = content) {
is NFTCollections.Content.Collections -> content.copy(
source = source,
)
is NFTCollections.Content.Error -> content
},
)
private suspend fun List<SdkNFTCollection>.mergeWithStoredAssets(
userWalletId: UserWalletId,
network: Network,
): List<SdkNFTCollection> {
val storedCollections =
getNFTPersistenceStore(network)
getNFTPersistenceStore(userWalletId, network)
.getCollectionsSync()
.orEmpty()
.associateBy(SdkNFTCollection::identifier)
@ -224,4 +257,11 @@ internal class DefaultNFTRepository @Inject constructor(
}
}
}
private fun Pair<UserWalletId, Network>.formatted(): String {
val (walletId, network) = this
return walletId.stringValue + "_" + network.id.value + "_" + network.derivationPath.value
}
private fun Network.canHandleNFTs(): Boolean = Blockchain.fromNetworkId(backendId)?.canHandleNFTs() == true
}

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.tokens.models)
/** Project - Utils */
implementation(projects.core.utils)

View file

@ -43,11 +43,9 @@ internal class DefaultVisaRepository @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val visaApiRequestMaker: VisaApiRequestMaker,
private val visaApi: TangemVisaApi,
private val visaCurrencyFactory: VisaCurrencyFactory,
) : VisaRepository {
private val currencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
VisaCurrencyFactory()
}
private val txDetailsFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
VisaTxDetailsFactory()
}
@ -79,20 +77,22 @@ internal class DefaultVisaRepository @Inject constructor(
private suspend fun fetchVisaCurrency(userWalletId: UserWalletId, address: String) {
val contractInfoProvider = visaLibLoader.getOrCreateProvider()
val paymentAccountAddress = getPaymentAccountAddress(userWalletId)
val userWallet = findVisaUserWallet(userWalletId)
parZip(
dispatchers.io,
{
contractInfoProvider.getContractInfo(
walletAddress = address,
paymentAccountAddress = getPaymentAccountAddress(userWalletId),
paymentAccountAddress = paymentAccountAddress,
)
},
{ getFiatRate() },
{ contractInfo, fiatRate ->
fetchedCurrencies.update { value ->
value.apply {
put(address, currencyFactory.create(contractInfo, fiatRate))
put(address, visaCurrencyFactory.create(userWallet, contractInfo, fiatRate))
}
}
},
@ -157,7 +157,7 @@ internal class DefaultVisaRepository @Inject constructor(
)
}
// TODO select correct account when multiple accounts are available (will be implemented when backend is ready)
// There will be only one payment account (backend has filtered for us), so we can take the first one
customerInfo.paymentAccounts.firstOrNull()?.paymentAccountAddress
}.getOrNull()
@ -187,14 +187,14 @@ internal class DefaultVisaRepository @Inject constructor(
}
}
private suspend fun getFiatRate(): BigDecimal? {
private suspend fun getFiatRate(): BigDecimal {
val fiatCurrencyId = VisaConstants.fiatCurrency.code.lowercase()
val quotes = tangemTechApi.getQuotes(
currencyId = fiatCurrencyId,
coinIds = VisaConstants.TOKEN_ID,
).getOrThrow()
return quotes.quotes[VisaConstants.TOKEN_ID]?.price
return quotes.quotes[VisaConstants.TOKEN_ID]?.price ?: error("No price found")
}
private fun makeWalletAddresses(userWallet: UserWallet): Set<Address> {

View file

@ -1,16 +1,28 @@
package com.tangem.data.visa.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.currency.getNetwork
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.NetworkAddress
import com.tangem.domain.visa.model.VisaCurrency
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.lib.visa.model.VisaContractInfo
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.Instant
import java.math.BigDecimal
import java.math.BigInteger
import javax.inject.Inject
internal class VisaCurrencyFactory {
internal class VisaCurrencyFactory @Inject constructor(
private val cryptoCurrencyFactory: CryptoCurrencyFactory,
private val excludedBlockchains: ExcludedBlockchains,
) {
fun create(contractInfo: VisaContractInfo, fiatRate: BigDecimal?): VisaCurrency {
fun create(userWallet: UserWallet, contractInfo: VisaContractInfo, fiatRate: BigDecimal): VisaCurrency {
val now = Instant.now()
val currentLimit = if (contractInfo.limitsChangeDate > now) {
contractInfo.oldLimits
@ -19,11 +31,30 @@ internal class VisaCurrencyFactory {
}
val remainingOtpLimit = getRemainingOtp(currentLimit, now)
val currencyNetwork = getNetwork(
blockchain = Blockchain.Polygon,
extraDerivationPath = null,
derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider,
excludedBlockchains = excludedBlockchains,
canHandleTokens = true,
) ?: error("Unable to create network for Visa currency")
val cryptoCurrency = cryptoCurrencyFactory.createToken(
network = currencyNetwork,
rawId = CryptoCurrency.RawID(VisaConstants.TOKEN_ID),
name = contractInfo.token.name,
symbol = contractInfo.token.symbol,
decimals = contractInfo.token.decimals,
contractAddress = contractInfo.token.address,
)
return VisaCurrency(
cryptoCurrency = cryptoCurrency,
symbol = contractInfo.token.symbol,
networkName = VisaConstants.NETWORK_NAME,
decimals = contractInfo.token.decimals,
fiatRate = fiatRate,
priceChange = BigDecimal.ZERO, // [Second Visa Iteration] pass to create() params if needed
fiatCurrency = VisaConstants.fiatCurrency,
balances = with(contractInfo) {
VisaCurrency.Balances(
@ -40,6 +71,12 @@ internal class VisaCurrencyFactory {
singleTransaction = currentLimit.singleTransactionLimit,
expirationDate = getLimitsExpirationDate(currentLimit, now),
),
paymentAccountAddress = NetworkAddress.Single(
NetworkAddress.Address(
value = contractInfo.paymentAccountAddress,
type = NetworkAddress.Address.Type.Primary,
),
),
)
}

View file

@ -208,6 +208,10 @@ internal class DefaultWalletsRepository(
.getObjectMap<Boolean>(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY)
.map { it[userWalletId.stringValue] == true }
override fun nftEnabledStatuses(): Flow<Map<UserWalletId, Boolean>> = appPreferencesStore
.getObjectMap<Boolean>(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY)
.map { it.mapKeys { UserWalletId(it.key) } }
override suspend fun enableNFT(userWalletId: UserWalletId) {
appPreferencesStore.editData {
it.setObjectMap(

View file

@ -17,4 +17,14 @@ data class NFTCollections(
val error: Throwable,
) : Content()
}
companion object {
fun empty(network: Network) = NFTCollections(
network = network,
content = Content.Collections(
collections = null,
source = StatusSource.ACTUAL,
),
)
}
}

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 FetchNFTCollectionsUseCase(
private val currenciesRepository: CurrenciesRepository,
private val nftRepository: NFTRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId) {
val currencies = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId)
nftRepository.refreshCollections(userWalletId, currencies.map { it.network }.distinct())
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.domain.nft
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.repository.NFTRepository
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
class GetNFTCollectionsUseCase(
private val currenciesRepository: CurrenciesRepository,
private val nftRepository: NFTRepository,
) {
@OptIn(ExperimentalCoroutinesApi::class)
fun launch(userWalletId: UserWalletId): Flow<List<NFTCollections>> = currenciesRepository
.getWalletCurrenciesUpdates(userWalletId)
.flatMapLatest {
val networks = it
.map { cryptoCurrency -> cryptoCurrency.network }
.distinct()
nftRepository.observeCollections(userWalletId, networks)
}
}

View file

@ -1,17 +1,22 @@
package com.tangem.domain.visa.model
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.NetworkAddress
import org.joda.time.DateTime
import java.math.BigDecimal
data class VisaCurrency(
val cryptoCurrency: CryptoCurrency,
val networkName: String,
val symbol: String,
val decimals: Int,
val fiatRate: BigDecimal?,
val fiatRate: BigDecimal,
val priceChange: BigDecimal,
val fiatCurrency: AppCurrency,
val balances: Balances,
val limits: Limits,
val paymentAccountAddress: NetworkAddress,
) {
data class Balances(

View file

@ -1,6 +1,8 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
@ -19,6 +21,7 @@ dependencies {
api(projects.domain.core)
implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.tangemSdkApi)
implementation(projects.domain.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
@ -29,4 +32,9 @@ dependencies {
implementation(tangemDeps.blockchain) // android-library
implementation(tangemDeps.card.core)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// end
}

View file

@ -3,14 +3,17 @@ package com.tangem.domain.wallets.builder
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
class UserWalletBuilder(
private val scanResponse: ScanResponse,
class UserWalletBuilder @AssistedInject constructor(
@Assisted private val scanResponse: ScanResponse,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val getCardImageUseCase: GetCardImageUseCase = GetCardImageUseCase(),
private val getCardImageUseCase: GetCardImageUseCase,
) {
private var backupCardsIds: Set<String> = emptySet()
private var hasBackupError: Boolean = false
@ -56,4 +59,10 @@ class UserWalletBuilder(
}
}
}
@AssistedFactory
interface Factory {
fun create(scanResponse: ScanResponse): UserWalletBuilder
}
}

View file

@ -32,6 +32,8 @@ interface WalletsRepository {
fun nftEnabledStatus(userWalletId: UserWalletId): Flow<Boolean>
fun nftEnabledStatuses(): Flow<Map<UserWalletId, Boolean>>
suspend fun enableNFT(userWalletId: UserWalletId)
suspend fun disableNFT(userWalletId: UserWalletId)

View file

@ -6,16 +6,15 @@ import com.tangem.domain.common.TwinCardNumber
import com.tangem.domain.common.TwinsHelper
import com.tangem.domain.wallets.models.Artwork
import com.tangem.operations.attestation.OnlineCardVerifier
import com.tangem.operations.attestation.TangemApi
/**
* Use case for getting card image url
*
* @property verifier REST API
* @property verifier online card verifier
*
[REDACTED_AUTHOR]
*/
class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardVerifier()) {
class GetCardImageUseCase(private val verifier: OnlineCardVerifier) {
/**
* Get card image url
@ -30,7 +29,11 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardV
if (artworkId.isNullOrEmpty()) {
getFallbackArtworkUrl(cardId)
} else {
getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)
OnlineCardVerifier.getUrlForArtwork(
cardId = cardId,
cardPublicKey = cardPublicKey.toHexString(),
artworkId = artworkId,
)
}
}
@ -51,9 +54,4 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardV
}
}
}
private fun getUrlForArtwork(cardId: String, cardPublicKeyHex: String, artworkId: String): String {
return TangemApi.Companion.BaseUrl.VERIFY.url + TangemApi.ARTWORK +
"?artworkId=$artworkId&CID=$cardId&publicKey=$cardPublicKeyHex"
}
}

View file

@ -23,7 +23,6 @@ import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.SaveWalletError
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase
import com.tangem.features.details.impl.R
@ -38,7 +37,7 @@ import kotlin.coroutines.resume
internal class UserWalletSaver @Inject constructor(
private val scanCardProcessor: ScanCardProcessor,
private val saveWalletUseCase: SaveWalletUseCase,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val shouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase,
private val reduxStateHolder: ReduxStateHolder,
private val messageSender: UiMessageSender,
@ -56,17 +55,20 @@ internal class UserWalletSaver @Inject constructor(
}
},
ifRight = { scanResponse ->
recover(block = {
scanResponse ?: return
val userWallet = createUserWallet(scanResponse)
saveWallet(userWallet)
}, recover = {
recover(
block = {
scanResponse ?: return
val userWallet = createUserWallet(scanResponse)
saveWallet(userWallet)
},
recover = {
val message = it.message
if (!message.isNullOrEmpty()) {
messageSender.send(SnackbarMessage(message))
}
},)
},
)
},
)
}
@ -110,7 +112,7 @@ internal class UserWalletSaver @Inject constructor(
}
private suspend fun Raise<Error>.createUserWallet(response: ScanResponse): UserWallet {
val userWallet = UserWalletBuilder(response, generateWalletNameUseCase).build()
val userWallet = userWalletBuilderFactory.create(scanResponse = response).build()
return ensureNotNull(userWallet) { Error.Unknown }
}

View file

@ -26,6 +26,9 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Domain modules */
implementation(projects.domain.nft.models)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)

View file

@ -10,7 +10,6 @@ import com.tangem.domain.common.TapWorkarounds.canSkipBackup
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
@ -25,7 +24,7 @@ import javax.inject.Inject
internal class Wallet1ChooseOptionModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val cardRepository: CardRepository,
private val saveWalletUseCase: SaveWalletUseCase,
private val analyticsHandler: AnalyticsEventHandler,
@ -67,7 +66,7 @@ internal class Wallet1ChooseOptionModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase).build(),
value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
}

View file

@ -15,7 +15,6 @@ import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent
@ -43,7 +42,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
private val getCardInfoUseCase: GetCardInfoUseCase,
private val cardRepository: CardRepository,
private val analyticsHandler: AnalyticsEventHandler,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val saveWalletUseCase: SaveWalletUseCase,
) : Model() {
@ -146,7 +145,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase).build(),
value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(),
lazyMessage = { "User wallet not created" },
)
}

View file

@ -20,7 +20,6 @@ import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
@ -49,7 +48,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private val tangemSdkManager: TangemSdkManager,
private val getCardInfoUseCase: GetCardInfoUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val cardRepository: CardRepository,
private val onboardingRepository: OnboardingRepository,
@ -279,7 +278,7 @@ internal class MultiWalletFinalizeModel @Inject constructor(
private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet {
return requireNotNull(
value = UserWalletBuilder(scanResponse, generateWalletNameUseCase)
value = userWalletBuilderFactory.create(scanResponse = scanResponse)
.backupCardsIds(backupCardIds.toSet())
.hasBackupError(walletHasBackupError)
.build(),

View file

@ -18,21 +18,25 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMul
import com.tangem.operations.backup.BackupService
import com.tangem.sdk.api.BackupServiceHolder
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class OnboardingMultiWalletModel @Inject constructor(
paramsContainer: ParamsContainer,
analyticsHandler: AnalyticsEventHandler,
override val dispatchers: CoroutineDispatcherProvider,
private val router: Router,
private val analyticsHandler: AnalyticsEventHandler,
private val backupServiceHolder: BackupServiceHolder,
private val onboardingRepository: OnboardingRepository,
private val getCardImageUseCase: GetCardImageUseCase,
) : Model() {
private val params = paramsContainer.require<OnboardingMultiWalletComponent.Params>()
private val getCardImageUseCase = GetCardImageUseCase()
private val _uiState = MutableStateFlow(OnboardingMultiWalletUM())
val state = MutableStateFlow(

View file

@ -14,7 +14,6 @@ import com.tangem.domain.visa.repository.VisaAuthRepository
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Config
import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Params
import com.tangem.features.onboarding.v2.visa.impl.route.OnboardingVisaRoute
@ -35,7 +34,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
private val visaAuthRepository: VisaAuthRepository,
private val visaAuthTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaOTPStorage,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
) : Model() {
@ -110,9 +109,8 @@ internal class OnboardingVisaInProgressModel @Inject constructor(
val newActivationStatus = VisaCardActivationStatus.Activated(visaAuthTokens = authTokens)
requireNotNull(
value = UserWalletBuilder(
scanResponse.copy(visaCardActivationStatus = newActivationStatus),
generateWalletNameUseCase,
value = userWalletBuilderFactory.create(
scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus),
).build(),
lazyMessage = { "User wallet not created" },
)

View file

@ -5,6 +5,7 @@ import com.tangem.common.extensions.toHexString
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.extensions.stringReference
import com.tangem.datasource.local.visa.VisaAuthTokenStorage
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.visa.SetVisaPinCodeUseCase
@ -13,7 +14,6 @@ import com.tangem.domain.visa.model.VisaCardId
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.OnboardingVisaPinCodeComponent
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -31,7 +31,7 @@ import javax.inject.Inject
internal class OnboardingVisaPinCodeModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
private val userWalletsListManager: UserWalletsListManager,
private val authTokenStorage: VisaAuthTokenStorage,
private val otpStorage: VisaAuthTokenStorage,
@ -57,24 +57,28 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
}
private fun onPinCodeChange(pin: String) {
if (pin.all { it.isDigit() }) {
if (PinCodeValidation.validateAllDigits(pin)) {
val isError = PinCodeValidation.validateLength(pin) && PinCodeValidation.validate(pin).not()
_uiState.update {
it.copy(
pinCode = pin,
submitButtonEnabled = checkPinCode(pin),
submitButtonEnabled = PinCodeValidation.validate(pin),
error = if (isError) stringReference("Invalid PIN: avoid sequences or repeats") else null,
)
}
}
}
private fun onSubmitClick() {
if (checkPinCode(_uiState.value.pinCode).not()) return
val pinCode = _uiState.value.pinCode
if (PinCodeValidation.validate(pinCode).not()) return
modelScope.launch {
loading(true)
setVisaPinCodeUseCase(
pinCode = _uiState.value.pinCode,
pinCode = pinCode,
visaCardId = visaCardId,
activationOrderId = params.activationOrderInfo.orderId,
).onLeft {
@ -88,10 +92,6 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
}
}
private fun checkPinCode(pin: String): Boolean {
return pin.length == PIN_CODE_LENGTH
}
private fun loading(state: Boolean) {
_uiState.update { it.copy(submitButtonLoading = state) }
}
@ -111,9 +111,8 @@ internal class OnboardingVisaPinCodeModel @Inject constructor(
)
requireNotNull(
value = UserWalletBuilder(
scanResponse.copy(visaCardActivationStatus = newActivationStatus),
generateWalletNameUseCase,
value = userWalletBuilderFactory.create(
scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus),
).build(),
lazyMessage = { "User wallet not created" },
)

View file

@ -0,0 +1,31 @@
package com.tangem.features.onboarding.v2.visa.impl.child.pincode.model
internal object PinCodeValidation {
private const val PIN_CODE_LENGTH = 4
fun validate(pinCode: String): Boolean {
return sequenceOf(
::validateAllDigits,
::validateLength,
::validateNoRepeatedDigits,
::validateNoConsecutiveDigits,
).all { it(pinCode) }
}
fun validateAllDigits(pinCode: String): Boolean {
return pinCode.all { it.isDigit() }
}
fun validateLength(pinCode: String): Boolean {
return pinCode.length == PIN_CODE_LENGTH
}
private fun validateNoRepeatedDigits(pinCode: String): Boolean {
return pinCode.toSet().size == PIN_CODE_LENGTH
}
private fun validateNoConsecutiveDigits(pinCode: String): Boolean {
return pinCode.windowed(2).all { it[0] + 1 == it[1] }.not()
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -31,7 +32,10 @@ import com.tangem.common.ui.navigationButtons.NavigationButton
import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM
@ -91,13 +95,29 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod
@Composable
private fun PinCodeSection(state: OnboardingVisaPinCodeUM, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
PinCode(
modifier = modifier,
value = state.pinCode,
onValueChange = state.onPinCodeChange,
focusRequester = focusRequester,
)
PinCode(
modifier = modifier,
value = state.pinCode,
onValueChange = state.onPinCodeChange,
focusRequester = focusRequester,
)
AnimatedVisibility(
visible = state.error != null,
) {
val error = remember(this) { requireNotNull(state.error) }
Column {
SpacerH4()
Text(
text = error.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.warning,
textAlign = TextAlign.Center,
)
}
}
}
LaunchedEffect(Unit) {
delay(timeMillis = 300)
@ -134,9 +154,11 @@ private fun PinCode(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
},),
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
},
),
modifier = Modifier
.alpha(alpha = 0.01f)
.focusRequester(focusRequester),
@ -194,6 +216,7 @@ private fun Preview() {
OnboardingVisaPinCode(
state = OnboardingVisaPinCodeUM(
pinCode = "1234",
error = stringReference("PIN Code error"),
),
)
}

View file

@ -1,7 +1,10 @@
package com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state
import com.tangem.core.ui.extensions.TextReference
internal data class OnboardingVisaPinCodeUM(
val pinCode: String = "",
val error: TextReference? = null,
val onPinCodeChange: (String) -> Unit = {},
val submitButtonLoading: Boolean = false,
val submitButtonEnabled: Boolean = true,

View file

@ -90,6 +90,8 @@ dependencies {
implementation(projects.domain.onramp)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
//TODO: Create api/impl modules for onboarding [REDACTED_JIRA]
implementation(projects.features.onboarding)
@ -108,6 +110,7 @@ dependencies {
implementation(projects.features.wallet.api)
implementation(projects.features.walletSettings.api)
implementation(projects.features.biometry.api)
implementation(projects.features.nft.api)
/** Common modules */
implementation(projects.common)

View file

@ -10,9 +10,11 @@ import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.nft.FetchNFTCollectionsUseCase
import com.tangem.domain.settings.*
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
@ -41,6 +43,7 @@ import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -76,6 +79,8 @@ internal class WalletModel @Inject constructor(
private val walletFeatureToggles: WalletFeatureToggles,
private val biometryFeatureToggles: BiometryFeatureToggles,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val fetchNFTCollectionsUseCase: FetchNFTCollectionsUseCase,
private val walletsRepository: WalletsRepository,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -86,6 +91,7 @@ internal class WalletModel @Inject constructor(
private val walletsUpdateJobHolder = JobHolder()
private val refreshWalletJobHolder = JobHolder()
private val expressStatusJobHolder = JobHolder()
private val walletsNFTsUpdateJobHolder = JobHolder()
private var needToRefreshWallet = false
private var expressTxStatusTaskScheduler = SingleTaskScheduler<Unit>()
@ -103,6 +109,7 @@ internal class WalletModel @Inject constructor(
subscribeToScreenBackgroundState()
subscribeOnPushNotificationsPermission()
subscribeOnExpressTransactionsUpdates()
subscribeOnNFTUpdates()
clickIntents.initialize(innerWalletRouter, modelScope)
}
@ -160,7 +167,10 @@ internal class WalletModel @Inject constructor(
.conflate()
.distinctUntilChanged()
.map {
walletsUpdateActionResolver.resolve(wallets = it, currentState = stateHolder.value)
walletsUpdateActionResolver.resolve(
wallets = it,
currentState = stateHolder.value,
)
}
.onEach(::updateWallets)
.flowOn(dispatchers.main)
@ -256,6 +266,29 @@ internal class WalletModel @Inject constructor(
}.saveIn(expressStatusJobHolder)
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun subscribeOnNFTUpdates() {
getWalletsUseCase()
.conflate()
.distinctUntilChanged()
.flatMapLatest { wallets ->
wallets
.map { wallet ->
walletsRepository.nftEnabledStatus(wallet.walletId)
.distinctUntilChanged()
.onEach { nftEnabled ->
if (nftEnabled) {
fetchNFTCollectionsUseCase.invoke(wallet.walletId)
}
}
}
.merge()
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
.saveIn(walletsNFTsUpdateJobHolder)
}
private fun needToRefreshTimer() {
modelScope.launch {
delay(REFRESH_WALLET_BACKGROUND_TIMER_MILLIS)

View file

@ -231,7 +231,10 @@ internal class WalletsUpdateActionResolver @Inject constructor(
* @property prevWalletId previous selected wallet id
* @property selectedWallet selected wallet
*/
data class ReinitializeWallet(val prevWalletId: UserWalletId, val selectedWallet: UserWallet) : Action() {
data class ReinitializeWallet(
val prevWalletId: UserWalletId,
val selectedWallet: UserWallet,
) : Action() {
override fun toString(): String {
return "ReinitializeWallet(prevWalletId = $prevWalletId, selectedWallet = ${selectedWallet.walletId})"

View file

@ -2,29 +2,19 @@ package com.tangem.feature.wallet.child.wallet.model.intents
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.visa.GetVisaCurrencyUseCase
import com.tangem.domain.visa.GetVisaTxDetailsUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.BalancesAndLimitsBottomSheetConverter
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.VisaTxDetailsBottomSheetConverter
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
internal interface VisaWalletIntents {
fun onDepositClick()
fun onBalancesAndLimitsClick()
fun onVisaTransactionClick(id: String)
@ -36,7 +26,6 @@ internal interface VisaWalletIntents {
internal class VisaWalletIntentsImplementor @Inject constructor(
private val stateController: WalletStateController,
private val eventSender: WalletEventSender,
private val getCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val getVisaCurrencyUseCase: GetVisaCurrencyUseCase,
private val getVisaTxDetailsUseCase: GetVisaTxDetailsUseCase,
private val dispatchers: CoroutineDispatcherProvider,
@ -46,38 +35,6 @@ internal class VisaWalletIntentsImplementor @Inject constructor(
BalancesAndLimitsBottomSheetConverter(eventSender)
}
override fun onDepositClick() {
val userWalletId = stateController.getSelectedWalletId()
modelScope.launch(dispatchers.main) {
val currencyStatus = getPrimaryCurrencyStatus(userWalletId) ?: return@launch
createReceiveBottomSheetContent(currencyStatus)?.let { content ->
stateController.showBottomSheet(content)
}
}
}
private fun createReceiveBottomSheetContent(currencyStatus: CryptoCurrencyStatus): TangemBottomSheetConfigContent? {
val currency = currencyStatus.currency
val addresses = currencyStatus.value.networkAddress?.availableAddresses
if (addresses == null) {
Timber.e("Addresses should not be null")
return null
}
return TokenReceiveBottomSheetConfig(
name = currency.name,
symbol = currency.symbol,
network = currency.network.name,
addresses = addresses.mapToAddressModels(currency).toImmutableList(),
showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE,
onCopyClick = { /* no-op */ },
onShareClick = { /* no-op */ },
)
}
override fun onBalancesAndLimitsClick() {
modelScope.launch(dispatchers.main) {
val userWalletId = stateController.getSelectedWalletId()
@ -95,14 +52,6 @@ internal class VisaWalletIntentsImplementor @Inject constructor(
}
}
private suspend fun getPrimaryCurrencyStatus(userWalletId: UserWalletId): CryptoCurrencyStatus? {
return getCurrencyStatusUseCase(userWalletId)
.getOrElse {
Timber.e("Failed to get primary currency $it")
null
}
}
override fun onVisaTransactionClick(id: String) {
modelScope.launch(dispatchers.main) {
val userWalletId = stateController.getSelectedWalletId()

View file

@ -5,6 +5,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.nft.FetchNFTCollectionsUseCase
import com.tangem.domain.onramp.FetchHotCryptoUseCase
import com.tangem.domain.settings.NeverToShowWalletsScrollPreview
import com.tangem.domain.tokens.FetchCardTokenListUseCase
@ -14,6 +15,7 @@ import com.tangem.domain.tokens.FetchTokenListUseCase.RefreshMode
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.feature.wallet.presentation.router.InnerWalletRouter
import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase
import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
@ -50,6 +52,8 @@ internal class WalletClickIntents @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val onrampFeatureToggles: OnrampFeatureToggles,
private val fetchHotCryptoUseCase: FetchHotCryptoUseCase,
private val fetchNFTCollectionsUseCase: FetchNFTCollectionsUseCase,
private val isWalletNFTEnabledSyncUseCase: IsWalletNFTEnabledSyncUseCase,
) : BaseWalletClickIntents(),
WalletCardClickIntents by walletCardClickIntentsImplementor,
WalletWarningsClickIntents by warningsClickIntentsImplementer,
@ -140,6 +144,10 @@ internal class WalletClickIntents @Inject constructor(
if (onrampFeatureToggles.isHotTokensEnabled) {
async { fetchHotCryptoUseCase() }.let(::add)
}
if (isWalletNFTEnabledSyncUseCase.invoke(userWallet.walletId)) {
async { fetchNFTCollectionsUseCase(userWalletId = userWallet.walletId) }.let(::add)
}
}
.awaitAll()

View file

@ -120,6 +120,12 @@ internal object WalletScreenPreviewData {
),
bottomSheetConfig = null,
tokensListState = textContentTokensState,
nftState = WalletNFTItemUM.Content(
previews = persistentListOf(WalletNFTItemUM.Content.CollectionPreview.Image("img1")),
collectionsCount = 1,
assetsCount = 3,
isFlickering = false,
),
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.feature.wallet.presentation.wallet.domain
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.features.nft.NFTFeatureToggles
import kotlinx.coroutines.flow.firstOrNull
class IsWalletNFTEnabledSyncUseCase(
private val walletsRepository: WalletsRepository,
private val nftFeatureToggles: NFTFeatureToggles,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Boolean = if (nftFeatureToggles.isNFTEnabled) {
walletsRepository
.nftEnabledStatuses()
.firstOrNull()
?.let { it[userWalletId] }
?: false
} else {
false
}
}

View file

@ -8,7 +8,6 @@ import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.wallets.builder.UserWalletBuilder
import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import javax.inject.Inject
@ -16,7 +15,7 @@ import javax.inject.Inject
internal class ScanCardToUnlockWalletClickHandler @Inject constructor(
private val scanCardProcessor: ScanCardProcessor,
private val saveWalletUseCase: SaveWalletUseCase,
private val generateWalletNameUseCase: GenerateWalletNameUseCase,
private val userWalletBuilderFactory: UserWalletBuilder.Factory,
) {
private var scanFailsCounter = 0
@ -34,7 +33,7 @@ internal class ScanCardToUnlockWalletClickHandler @Inject constructor(
scanFailsCounter = 0
// If card's public key is null then user wallet will be null
val scannedWallet = UserWalletBuilder(result.data, generateWalletNameUseCase).build()
val scannedWallet = userWalletBuilderFactory.create(scanResponse = result.data).build()
ensure(walletId == scannedWallet?.walletId) {
ScanCardToUnlockWalletError.WrongCardIsScanned

View file

@ -40,7 +40,7 @@ internal enum class Wallet2CobrandImage(
BitcoinGold(
cards2ResId = R.drawable.ill_bitcoingold_card2_120_106,
cards3ResId = R.drawable.ill_bitcoingold_card3_120_106,
batchIds = setOf("AF71"),
batchIds = setOf("AF71", "AF990016", "AF990009"),
),
BitcoinPizzaDay(

View file

@ -3,10 +3,12 @@ package com.tangem.feature.wallet.presentation.wallet.loaders.implementors
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.nft.GetNFTCollectionsUseCase
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.tokens.ApplyTokenListSortingUseCase
import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
@ -21,6 +23,7 @@ import com.tangem.feature.wallet.presentation.wallet.subscribers.MultiWalletActi
import com.tangem.feature.wallet.presentation.wallet.subscribers.MultiWalletTokenListSubscriber
import com.tangem.feature.wallet.presentation.wallet.subscribers.MultiWalletWarningsSubscriber
import com.tangem.feature.wallet.presentation.wallet.subscribers.WalletSubscriber
import com.tangem.features.nft.NFTFeatureToggles
import com.tangem.features.swap.SwapFeatureToggles
@Suppress("LongParameterList")
@ -34,6 +37,7 @@ internal class MultiWalletContentLoader(
private val walletWarningsSingleEventSender: WalletWarningsSingleEventSender,
private val walletWithFundsChecker: WalletWithFundsChecker,
private val tokenListStore: MultiWalletTokenListStore,
private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val applyTokenListSortingUseCase: ApplyTokenListSortingUseCase,
private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory,
@ -42,6 +46,8 @@ internal class MultiWalletContentLoader(
private val getStoryContentUseCase: GetStoryContentUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val deepLinksRegistry: DeepLinksRegistry,
private val nftFeatureToggles: NFTFeatureToggles,
private val walletsRepository: WalletsRepository,
) : WalletContentLoader(id = userWallet.walletId) {
override fun create(): List<WalletSubscriber> {
@ -58,6 +64,15 @@ internal class MultiWalletContentLoader(
runPolkadotAccountHealthCheckUseCase = runPolkadotAccountHealthCheckUseCase,
deepLinksRegistry = deepLinksRegistry,
).let(::add)
if (nftFeatureToggles.isNFTEnabled) {
WalletNFTListSubscriber(
userWallet = userWallet,
getNFTCollectionsUseCase = getNFTCollectionsUseCase,
stateHolder = stateHolder,
walletsRepository = walletsRepository,
clickIntents = clickIntents,
).let(::add)
}
MultiWalletWarningsSubscriber(
userWallet = userWallet,
stateHolder = stateHolder,

View file

@ -3,10 +3,12 @@ package com.tangem.feature.wallet.presentation.wallet.loaders.implementors
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.nft.GetNFTCollectionsUseCase
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.tokens.ApplyTokenListSortingUseCase
import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender
@ -16,6 +18,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.GetMultiWalletWarnin
import com.tangem.feature.wallet.presentation.wallet.domain.MultiWalletTokenListStore
import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.features.nft.NFTFeatureToggles
import com.tangem.features.swap.SwapFeatureToggles
import javax.inject.Inject
@ -36,6 +39,9 @@ internal class MultiWalletContentLoaderFactory @Inject constructor(
private val getStoryContentUseCase: GetStoryContentUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val deepLinksRegistry: DeepLinksRegistry,
private val nftFeatureToggles: NFTFeatureToggles,
private val walletsRepository: WalletsRepository,
private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase,
) {
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): WalletContentLoader {
@ -56,6 +62,9 @@ internal class MultiWalletContentLoaderFactory @Inject constructor(
shouldSaveUserWalletsUseCase = shouldSaveUserWalletsUseCase,
swapFeatureToggles = swapFeatureToggles,
deepLinksRegistry = deepLinksRegistry,
nftFeatureToggles = nftFeatureToggles,
walletsRepository = walletsRepository,
getNFTCollectionsUseCase = getNFTCollectionsUseCase,
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.model.NOT_INITIALIZED_WALLET_INDEX
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState

View file

@ -1,6 +0,0 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
internal data class DepositButtonState(
val isEnabled: Boolean,
val onClick: () -> Unit,
)

View file

@ -22,6 +22,7 @@ internal sealed interface WalletState : WalletStateHolder {
sealed class MultiCurrency : WalletState {
abstract val tokensListState: WalletTokensListState
abstract val nftState: WalletNFTItemUM
data class Content(
override val pullToRefreshConfig: PullToRefreshConfig,
@ -30,6 +31,7 @@ internal sealed interface WalletState : WalletStateHolder {
override val warnings: ImmutableList<WalletNotification>,
override val bottomSheetConfig: TangemBottomSheetConfig?,
override val tokensListState: WalletTokensListState,
override val nftState: WalletNFTItemUM,
) : MultiCurrency()
data class Locked(
@ -46,6 +48,7 @@ internal sealed interface WalletState : WalletStateHolder {
) {
override val tokensListState = WalletTokensListState.ContentState.Locked
override val nftState: WalletNFTItemUM = WalletNFTItemUM.Hidden
}
}
@ -96,7 +99,6 @@ internal sealed interface WalletState : WalletStateHolder {
override val bottomSheetConfig: TangemBottomSheetConfig?,
override val balancesAndLimitBlockState: BalancesAndLimitsBlockState,
override val txHistoryState: TxHistoryState,
val depositButtonState: DepositButtonState,
) : Visa()
data class Locked(

View file

@ -38,7 +38,9 @@ internal class InitializeWalletsTransformer(
if (userWallet.isLocked) {
createLockedState(userWallet)
} else {
walletLoadingStateFactory.create(userWallet)
walletLoadingStateFactory.create(
userWallet = userWallet,
)
}
}
.toImmutableList(),

View file

@ -2,10 +2,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletLoadingStateFactory
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import kotlinx.collections.immutable.toImmutableList
@ -38,7 +38,11 @@ internal class ReinitializeWalletTransformer(
return prevState.copy(
wallets = prevState.wallets
.filterNot { it.walletCardState.id == prevWalletId }
.plus(element = walletLoadingStateFactory.create(newUserWallet))
.plus(
element = walletLoadingStateFactory.create(
userWallet = newUserWallet,
),
)
.toImmutableList(),
)
}

View file

@ -0,0 +1,23 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
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
internal class RemoveNFTCollectionsTransformer(
userWalletId: UserWalletId,
) : WalletStateTransformer(userWalletId) {
override fun transform(prevState: WalletState): WalletState = when (prevState) {
is WalletState.MultiCurrency.Content -> prevState.copy(
nftState = WalletNFTItemUM.Hidden,
)
is WalletState.SingleCurrency.Content,
is WalletState.Visa.Content,
is WalletState.MultiCurrency.Locked,
is WalletState.SingleCurrency.Locked,
is WalletState.Visa.Locked,
is WalletState.Visa.AccessTokenLocked,
-> prevState
}
}

View file

@ -0,0 +1,95 @@
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.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNFTItemUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import kotlinx.collections.immutable.toPersistentList
internal class SetNFTCollectionsTransformer(
userWalletId: UserWalletId,
private val nftCollections: List<NFTCollections>,
) : 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()
},
)
is WalletState.SingleCurrency.Content,
is WalletState.Visa.Content,
is WalletState.MultiCurrency.Locked,
is WalletState.SingleCurrency.Locked,
is WalletState.Visa.Locked,
is WalletState.Visa.AccessTokenLocked,
-> prevState
}
private fun createContentNFTItemUM(): WalletNFTItemUM.Content {
val collectionsContent = nftCollections
.map { it.content }
.filterIsInstance<NFTCollections.Content.Collections>()
val isFlickering = collectionsContent
.map { it.source }
.any { it == StatusSource.CACHE }
val collections = collectionsContent
.mapNotNull { it.collections }
.flatten()
return WalletNFTItemUM.Content(
previews = if (collections.size > NFT_COLLECTIONS_MAX_PREVIEWS_COUNT) {
collections
.take(NFT_COLLECTIONS_MAX_PREVIEWS_COUNT - 1)
.map { WalletNFTItemUM.Content.CollectionPreview.Image(it.logoUrl.orEmpty()) }
.plus(WalletNFTItemUM.Content.CollectionPreview.More)
.toPersistentList()
} else {
collections
.take(NFT_COLLECTIONS_MAX_PREVIEWS_COUNT)
.map { WalletNFTItemUM.Content.CollectionPreview.Image(it.logoUrl.orEmpty()) }
.toPersistentList()
},
collectionsCount = collections.size,
assetsCount = collections
.sumOf { it.count },
isFlickering = isFlickering,
)
}
private fun allCollectionsFailed() = nftCollections.all {
it.content is NFTCollections.Content.Error
}
private fun anyCollectionFailed() = nftCollections.any {
it.content is NFTCollections.Content.Error
}
private fun allLoadedCollectionsEmpty() = nftCollections
.map { it.content }
.filterIsInstance<NFTCollections.Content.Collections>()
.all { it.collections.isNullOrEmpty() }
private fun allCollectionsLoaded() = nftCollections.all {
val content = it.content
content is NFTCollections.Content.Collections &&
content.source != StatusSource.CACHE
}
private fun allCollectionsEmpty() = nftCollections.all {
val content = it.content
content is NFTCollections.Content.Collections &&
content.collections.isNullOrEmpty()
}
companion object {
private const val NFT_COLLECTIONS_MAX_PREVIEWS_COUNT = 4
}
}

View file

@ -3,7 +3,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.BalancesAndLimitsBlockState
import com.tangem.feature.wallet.presentation.wallet.state.model.DepositButtonState
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
@ -29,8 +28,8 @@ internal class SetRefreshStateTransformer(
}
is WalletState.Visa.Content -> {
prevState.copy(
buttons = prevState.buttons.toUpdatedState(),
pullToRefreshConfig = prevState.pullToRefreshConfig.toUpdatedState(isRefreshing),
depositButtonState = prevState.depositButtonState.toUpdatedState(isRefreshing),
balancesAndLimitBlockState = prevState.balancesAndLimitBlockState.toUpdatedState(isRefreshing),
)
}
@ -75,10 +74,6 @@ internal class SetRefreshStateTransformer(
}
}
private fun DepositButtonState.toUpdatedState(isRefreshing: Boolean): DepositButtonState {
return copy(isEnabled = !isRefreshing)
}
private fun BalancesAndLimitsBlockState.toUpdatedState(isRefreshing: Boolean): BalancesAndLimitsBlockState {
return when (this) {
is BalancesAndLimitsBlockState.Content -> copy(isEnabled = !isRefreshing)

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.common.util.getCardsCount
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.visa.exception.RefreshTokenExpiredException
import com.tangem.domain.visa.model.VisaCurrency
import com.tangem.domain.wallets.models.UserWallet
@ -15,11 +16,14 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditiona
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletManageButton
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import org.joda.time.DateTime
import org.joda.time.Days
internal class SetBalancesAndLimitsTransformer(
internal class SetVisaInfoTransformer(
private val userWallet: UserWallet,
private val maybeVisaCurrency: Either<Throwable, VisaCurrency>,
private val clickIntents: WalletClickIntents,
@ -35,15 +39,15 @@ internal class SetBalancesAndLimitsTransformer(
}
return prevState.copy(
buttons = createVisaButtonsDimmed(),
walletCardState = getErrorWalletCardState(prevState.walletCardState),
depositButtonState = prevState.depositButtonState.copy(isEnabled = false),
balancesAndLimitBlockState = BalancesAndLimitsBlockState.Error,
)
}
return prevState.copy(
buttons = createVisaButtons(visaCurrency = visaCurrency),
walletCardState = getContentWalletCardState(prevState.walletCardState, visaCurrency),
depositButtonState = prevState.depositButtonState.copy(isEnabled = true),
balancesAndLimitBlockState = getContentBlockState(visaCurrency),
)
}
@ -115,4 +119,43 @@ internal class SetBalancesAndLimitsTransformer(
onUnlockVisaAccessNotificationClick = clickIntents::onUnlockVisaAccessClick,
)
}
private fun createVisaButtonsDimmed(): PersistentList<WalletManageButton> {
return persistentListOf(
WalletManageButton.Receive(enabled = true, dimContent = true, onClick = {}, onLongClick = null),
WalletManageButton.Buy(enabled = true, dimContent = true, onClick = {}),
)
}
private fun createVisaButtons(visaCurrency: VisaCurrency): PersistentList<WalletManageButton> {
// [Second Visa Iteration] Make VisaCurrency contain CryptoCurrencyStatus
val cryptoCurrencyStatus = CryptoCurrencyStatus(
currency = visaCurrency.cryptoCurrency,
value = CryptoCurrencyStatus.Loaded(
amount = visaCurrency.balances.available,
fiatAmount = visaCurrency.balances.available.multiply(visaCurrency.fiatRate),
fiatRate = visaCurrency.fiatRate,
priceChange = visaCurrency.priceChange,
yieldBalance = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = visaCurrency.paymentAccountAddress,
sources = CryptoCurrencyStatus.Sources(),
),
)
return return persistentListOf(
WalletManageButton.Receive(
enabled = true,
dimContent = false,
onClick = { clickIntents.onReceiveClick(cryptoCurrencyStatus = cryptoCurrencyStatus) },
onLongClick = { clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus = cryptoCurrencyStatus) },
),
WalletManageButton.Buy(
enabled = true,
dimContent = false,
onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId) },
),
)
}
}

View file

@ -46,7 +46,9 @@ internal class UnlockWalletTransformer(
is WalletState.MultiCurrency.Locked,
is WalletState.SingleCurrency.Locked,
is WalletState.Visa.Locked,
-> walletLoadingStateFactory.create(userWallet = unlockedWallet)
-> walletLoadingStateFactory.create(
userWallet = unlockedWallet,
)
is WalletState.MultiCurrency.Content,
is WalletState.SingleCurrency.Content,
is WalletState.Visa.Content,

View file

@ -1,14 +1,14 @@
package com.tangem.feature.wallet.presentation.wallet.state.utils
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
@ -41,6 +41,7 @@ internal class WalletLoadingStateFactory(
warnings = persistentListOf(),
bottomSheetConfig = null,
tokensListState = WalletTokensListState.ContentState.Loading,
nftState = WalletNFTItemUM.Hidden,
)
}
@ -67,7 +68,7 @@ internal class WalletLoadingStateFactory(
return WalletState.Visa.Content(
pullToRefreshConfig = createPullToRefreshConfig(),
walletCardState = userWallet.toLoadingWalletCardState(),
buttons = createMultiWalletActions(userWallet),
buttons = createVisaDimmedButtons(),
warnings = persistentListOf(),
bottomSheetConfig = null,
balancesAndLimitBlockState = BalancesAndLimitsBlockState.Loading,
@ -76,12 +77,14 @@ internal class WalletLoadingStateFactory(
value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick),
),
),
depositButtonState = DepositButtonState(isEnabled = false, clickIntents::onDepositClick),
)
}
private fun createPullToRefreshConfig(): PullToRefreshConfig {
return PullToRefreshConfig(onRefresh = { clickIntents.onRefreshSwipe(it.value) }, isRefreshing = false)
return PullToRefreshConfig(
onRefresh = { clickIntents.onRefreshSwipe(it.value) },
isRefreshing = false,
)
}
private fun UserWallet.toLoadingWalletCardState(): WalletCardState {
@ -117,6 +120,13 @@ internal class WalletLoadingStateFactory(
)
}
private fun createVisaDimmedButtons(): PersistentList<WalletManageButton> {
return persistentListOf(
WalletManageButton.Receive(enabled = true, dimContent = true, onClick = {}, onLongClick = null),
WalletManageButton.Buy(enabled = true, dimContent = true, onClick = {}),
)
}
private fun createDimmedButtons(): PersistentList<WalletManageButton> {
return persistentListOf(
WalletManageButton.Receive(enabled = true, dimContent = true, onClick = {}, onLongClick = null),

View file

@ -13,7 +13,7 @@ import com.tangem.domain.visa.model.VisaTxHistoryItem
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetBalancesAndLimitsTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetVisaInfoTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTxHistoryCountTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTxHistoryItemsErrorTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTxHistoryItemsTransformer
@ -62,7 +62,7 @@ internal class VisaWalletSubscriber(
private fun setLoadedCurrencyState(maybeCurrency: Either<Throwable, VisaCurrency>) {
stateController.update(
SetBalancesAndLimitsTransformer(
SetVisaInfoTransformer(
userWallet = userWallet,
maybeVisaCurrency = maybeCurrency,
clickIntents = clickIntents,

View file

@ -0,0 +1,50 @@
package com.tangem.feature.wallet.presentation.wallet.subscribers
import com.tangem.domain.nft.GetNFTCollectionsUseCase
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.transformers.RemoveNFTCollectionsTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetNFTCollectionsTransformer
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,
) : WalletSubscriber() {
@OptIn(ExperimentalCoroutinesApi::class)
override fun create(coroutineScope: CoroutineScope): Flow<*> = walletsRepository
.nftEnabledStatus(userWallet.walletId)
.distinctUntilChanged()
.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)
.shareIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(),
replay = 1,
)
.onEach {
stateHolder.update(
SetNFTCollectionsTransformer(userWallet.walletId, it),
)
}
} else {
// otherwise, hide NFT from wallet
stateHolder.update(
RemoveNFTCollectionsTransformer(userWallet.walletId),
)
emptyFlow()
}
}
}

View file

@ -80,12 +80,12 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.TokenActionsB
import com.tangem.feature.wallet.presentation.wallet.ui.components.WalletsList
import com.tangem.feature.wallet.presentation.wallet.ui.components.common.*
import com.tangem.feature.wallet.presentation.wallet.ui.components.common.actions
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.nftCollections
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton
import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.BalancesAndLimitsBottomSheet
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.VisaTxDetailsBottomSheet
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.balancesAndLimitsBlock
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.depositButton
import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator
import com.tangem.features.markets.entry.BottomSheetState
import com.tangem.features.markets.entry.MarketsEntryComponent
@ -230,11 +230,6 @@ private fun WalletContent(
}
(selectedWallet as? WalletState.Visa.Content)?.let {
depositButton(
modifier = itemModifier.fillMaxWidth(),
state = it.depositButtonState,
)
balancesAndLimitsBlock(
modifier = itemModifier,
state = it.balancesAndLimitBlockState,
@ -248,6 +243,8 @@ private fun WalletContent(
modifier = movableItemModifier,
)
nftCollections(state = selectedWallet, itemModifier = itemModifier)
organizeTokens(state = selectedWallet, itemModifier = itemModifier)
}
@ -706,6 +703,16 @@ internal fun LazyListScope.organizeTokens(state: WalletState, itemModifier: Modi
}
}
internal fun LazyListScope.nftCollections(state: WalletState, itemModifier: Modifier) {
(state as? WalletState.MultiCurrency)?.let {
nftCollections(
modifier = itemModifier,
state = it.nftState,
onClick = {},
)
}
}
@Composable
private fun ShowBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) {
if (bottomSheetConfig != null) {

View file

@ -131,7 +131,20 @@ private fun WalletNFTItemFailed(modifier: Modifier = Modifier) {
RowContentContainer(
modifier = modifier,
icon = {
CollectionsPreviewsPlaceholder()
Box(
modifier = Modifier
.size(TangemTheme.dimens.size36)
.clip(RoundedCornerShape(TangemTheme.dimens.radius8))
.background(TangemTheme.colors.field.primary),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens.size20),
painter = painterResource(R.drawable.ic_error_sync_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
},
text = {
Text(
@ -158,7 +171,12 @@ private fun WalletNFTItemLoading(modifier: Modifier = Modifier) {
RowContentContainer(
modifier = modifier,
icon = {
CollectionsPreviewsPlaceholder()
Box(
modifier = Modifier
.size(TangemTheme.dimens.size36)
.clip(RoundedCornerShape(TangemTheme.dimens.radius8))
.background(TangemTheme.colors.field.primary),
)
},
text = {
TextShimmer(
@ -178,16 +196,6 @@ private fun WalletNFTItemLoading(modifier: Modifier = Modifier) {
)
}
@Composable
private fun CollectionsPreviewsPlaceholder(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size36)
.clip(RoundedCornerShape(TangemTheme.dimens.radius8))
.background(TangemTheme.colors.field.primary),
)
}
@Composable
@Suppress("MagicNumber")
private fun BoxScope.CollectionsPreviews(previews: ImmutableList<CollectionPreview>, modifier: Modifier = Modifier) {
@ -207,7 +215,7 @@ private fun BoxScope.CollectionsPreviews(previews: ImmutableList<CollectionPrevi
is CollectionPreview.Image -> {
SubcomposeAsyncImage(
modifier = modifier,
model = s,
model = s.url,
loading = {
RectangleShimmer(radius = 0.dp)
},

View file

@ -0,0 +1,18 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNFTItemUM
import com.tangem.feature.wallet.presentation.wallet.ui.components.WalletNFTItem
private const val NFT_COLLECTIONS_CONTENT_TYPE = "NFTCollections"
internal fun LazyListScope.nftCollections(state: WalletNFTItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
item(key = NFT_COLLECTIONS_CONTENT_TYPE, contentType = NFT_COLLECTIONS_CONTENT_TYPE) {
WalletNFTItem(
modifier = modifier,
state = state,
onClick = onClick,
)
}
}

View file

@ -1,21 +0,0 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.visa
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.PrimaryButtonIconStart
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.DepositButtonState
private const val DEPOSIT_BUTTON_CONTENT_TYPE = "DepositButton"
internal fun LazyListScope.depositButton(state: DepositButtonState, modifier: Modifier = Modifier) {
item(key = DEPOSIT_BUTTON_CONTENT_TYPE, contentType = DEPOSIT_BUTTON_CONTENT_TYPE) {
PrimaryButtonIconStart(
modifier = modifier,
text = "Deposit",
iconResId = R.drawable.ic_arrow_down_24,
onClick = state.onClick,
enabled = state.isEnabled,
)
}
}

View file

@ -1,5 +1,5 @@
[versions]
tangemBlockchainSdk = "develop-977"
tangemBlockchainSdk = "develop-984"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-441"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.domain.wallets.models)
implementation(projects.domain.visa.models)
implementation(projects.core.configToggles)
implementation(projects.core.res)
/** Tangem libraries */

View file

@ -0,0 +1,18 @@
package com.tangem.sdk.api.di
import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles
import com.tangem.sdk.api.featuretoggles.DefaultCardSdkFeatureToggles
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface CardSdkFeatureTogglesModule {
@Binds
@Singleton
fun bindCardSdkFeatureToggles(impl: DefaultCardSdkFeatureToggles): CardSdkFeatureToggles
}

View file

@ -0,0 +1,17 @@
package com.tangem.sdk.api.di
import com.tangem.operations.attestation.OnlineCardVerifier
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object CardSdkModule {
@Provides
@Singleton
fun provideOnlineCardVerifier(): OnlineCardVerifier = OnlineCardVerifier()
}

View file

@ -0,0 +1,11 @@
package com.tangem.sdk.api.featuretoggles
/**
* Card SDK feature toggles
*
[REDACTED_AUTHOR]
*/
interface CardSdkFeatureToggles {
val isNewAttestationEnabled: Boolean
}

View file

@ -0,0 +1,12 @@
package com.tangem.sdk.api.featuretoggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import javax.inject.Inject
internal class DefaultCardSdkFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : CardSdkFeatureToggles {
override val isNewAttestationEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(name = "NEW_ATTESTATION_ENABLED")
}

View file

@ -81,7 +81,14 @@ internal class DefaultVisaContractInfoProvider(
{ fetchBalances(paymentAccount, paymentToken) },
{ fetchLimits(paymentAccount, paymentToken, walletAddress) },
{ token, balances, (oldLimit, newLimit, changeDate) ->
VisaContractInfo(token, balances, oldLimit, newLimit, changeDate)
VisaContractInfo(
token = token,
balances = balances,
oldLimits = oldLimit,
newLimits = newLimit,
paymentAccountAddress = paymentAccount.contractAddress,
limitsChangeDate = changeDate,
)
},
)

View file

@ -9,6 +9,7 @@ data class VisaContractInfo(
val balances: Balances,
val oldLimits: Limits,
val newLimits: Limits,
val paymentAccountAddress: String,
val limitsChangeDate: Instant,
) {