diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b36d773b95..700a9a7754 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,7 @@ dependencies { implementation(projects.domain.promo) implementation(projects.domain.promo.models) implementation(projects.domain.networks) + implementation(projects.domain.quotes) implementation(projects.common) implementation(projects.common.routing) @@ -152,6 +153,7 @@ dependencies { implementation(projects.data.nft) implementation(projects.data.onramp) implementation(projects.data.networks) + implementation(projects.data.quotes) /** Features */ implementation(projects.features.onboarding) @@ -318,6 +320,7 @@ dependencies { /** Chucker */ debugImplementation(deps.chucker) mockedImplementation(deps.chuckerStub) + externalImplementation(deps.chuckerStub) internalImplementation(deps.chuckerStub) releaseImplementation(deps.chuckerStub) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 29f73d909e..7a607593e3 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 29f73d909e14cb3760118b197b1929a6088bc40e +Subproject commit 7a607593e34f0977474613f077532215eaf0ffe6 diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 4cc52e4409..bb4aa08278 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -1,6 +1,9 @@ package com.tangem.tap import android.app.Application +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import coil.ImageLoader @@ -71,9 +74,7 @@ import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* import org.rekotlin.Store import kotlin.collections.set import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository @@ -228,12 +229,32 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat // endregion + private val appScope = MainScope() + override fun onCreate() { + enableStrictModeInDebug() super.onCreate() - init() + } - updateLogFiles() + private fun enableStrictModeInDebug() { + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectAll() + .penaltyLog() + .build(), + ) + StrictMode.setVmPolicy( + VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() + .build(), + ) + } } private fun updateLogFiles() { @@ -260,18 +281,31 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat foregroundActivityObserver = ForegroundActivityObserver() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) - // TODO: Try to performance and user experience. - // [REDACTED_JIRA] + // We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them. runBlocking { awaitAll( - async { featureTogglesManager.init() }, - async { excludedBlockchainsManager.init() }, - async { initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) }, + async { + featureTogglesManager.init() + }, + async { + excludedBlockchainsManager.init() + }, ) } - loadNativeLibraries() + appScope.launch { + initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) + launch(Dispatchers.IO) { + loadNativeLibraries() + walletConnect2Repository.init( + projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId, + ) + updateLogFiles() + } + } + ExceptionHandler.append(blockchainExceptionHandler) + if (LogConfig.network.blockchainSdkNetwork) { BlockchainSdkRetrofitBuilder.interceptors = listOf( createNetworkLoggingInterceptor(), @@ -287,9 +321,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat appPreferencesStore = appPreferencesStore, dispatchers = dispatchers, ) - appStateHolder.mainStore = store - walletConnect2Repository.init(projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId) + appStateHolder.mainStore = store } private fun createReduxStore(): Store { diff --git a/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt index a6128ddfe6..9859e3e4be 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt @@ -20,18 +20,22 @@ internal class DefaultVisaAuthTokenStorage @Inject constructor( private val dispatcherProvider: CoroutineDispatcherProvider, ) : VisaAuthTokenStorage { - private val secureStorage = AndroidSecureStorage( - preferences = SecureStorage.createEncryptedSharedPreferences( - context = applicationContext, - storageName = "visa_auth_storage", - ), - ) + private val secureStorage by lazy { + AndroidSecureStorage( + preferences = SecureStorage.createEncryptedSharedPreferences( + context = applicationContext, + storageName = "visa_auth_storage", + ), + ) + } - private val moshi = Moshi.Builder() - .add(KotlinJsonAdapterFactory()) - .build() + private val moshi by lazy { + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + } - private val tokensAdapter = moshi.adapter(VisaAuthTokens::class.java) + private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) } override suspend fun store(cardId: String, tokens: VisaAuthTokens) = withContext(dispatcherProvider.io) { val json = tokensAdapter.toJson(tokens) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt index f752d10dea..b87d3fbfdf 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt @@ -20,12 +20,14 @@ class DefaultVisaOTPStorage @Inject constructor( private val dispatcherProvider: CoroutineDispatcherProvider, ) : VisaOTPStorage { - private val secureStorage = AndroidSecureStorage( - preferences = SecureStorage.createEncryptedSharedPreferences( - context = applicationContext, - storageName = "visa_otp_storage", - ), - ) + private val secureStorage by lazy { + AndroidSecureStorage( + preferences = SecureStorage.createEncryptedSharedPreferences( + context = applicationContext, + storageName = "visa_otp_storage", + ), + ) + } override suspend fun saveOTP(cardId: String, data: VisaOtpData) = withContext(dispatcherProvider.io) { secureStorage.store(data.rootOTP, VISA_ROOT_OTP_KEY_PREFIX + cardId) diff --git a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt index f6467cc073..07f1c7e97f 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/ManageTokensDomainModule.kt @@ -5,6 +5,7 @@ import com.tangem.domain.managetokens.* import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.managetokens.repository.ManageTokensRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -73,6 +74,7 @@ internal object ManageTokensDomainModule { stakingRepository: StakingRepository, quotesRepository: QuotesRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): SaveManagedTokensUseCase { return SaveManagedTokensUseCase( @@ -84,6 +86,7 @@ internal object ManageTokensDomainModule { stakingRepository = stakingRepository, quotesRepository = quotesRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt index 3a568db5e6..b6efd9bd4f 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/MarketsDomainModule.kt @@ -5,6 +5,8 @@ import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.markets.* import com.tangem.domain.markets.repositories.MarketsTokenRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.single.SingleQuoteSupplier import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -49,8 +51,16 @@ object MarketsDomainModule { @Provides @Singleton - fun provideGetTokenQuotesUseCase(quotesRepository: QuotesRepository): GetCurrencyQuotesUseCase { - return GetCurrencyQuotesUseCase(quotesRepository = quotesRepository) + fun provideGetTokenQuotesUseCase( + quotesRepository: QuotesRepository, + singleQuoteSupplier: SingleQuoteSupplier, + tokensFeatureToggles: TokensFeatureToggles, + ): GetCurrencyQuotesUseCase { + return GetCurrencyQuotesUseCase( + quotesRepository = quotesRepository, + singleQuoteSupplier = singleQuoteSupplier, + tokensFeatureToggles = tokensFeatureToggles, + ) } @Provides @@ -63,6 +73,7 @@ object MarketsDomainModule { stakingRepository: StakingRepository, quotesRepository: QuotesRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): SaveMarketTokensUseCase { return SaveMarketTokensUseCase( @@ -73,6 +84,7 @@ object MarketsDomainModule { stakingRepository = stakingRepository, quotesRepository = quotesRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } diff --git a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt index a8cb6b030d..de24b448ec 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -66,4 +66,11 @@ internal object NFTDomainModule { GetNFTNetworkStatusUseCase( networksRepository = networksRepository, ) + + @Provides + @Singleton + fun providesGetNFTExploreUrlUseCase(nftRepository: NFTRepository): GetNFTExploreUrlUseCase = + GetNFTExploreUrlUseCase( + nftRepository = nftRepository, + ) } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt index 237b83807e..695196deaf 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TokensDomainModule.kt @@ -7,6 +7,9 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.promo.PromoRepository +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.single.SingleQuoteSupplier import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.* import com.tangem.domain.tokens.operations.BaseCurrenciesStatusesOperations @@ -35,6 +38,7 @@ internal object TokensDomainModule { stakingRepository: StakingRepository, quotesRepository: QuotesRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): AddCryptoCurrenciesUseCase { return AddCryptoCurrenciesUseCase( @@ -43,6 +47,7 @@ internal object TokensDomainModule { stakingRepository = stakingRepository, quotesRepository = quotesRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -55,6 +60,7 @@ internal object TokensDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): FetchTokenListUseCase { return FetchTokenListUseCase( @@ -63,6 +69,7 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, stakingRepository = stakingRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -168,6 +175,7 @@ internal object TokensDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): FetchCurrencyStatusUseCase { return FetchCurrencyStatusUseCase( @@ -176,6 +184,7 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, stakingRepository = stakingRepository, singleNetworkStatusFetcher = singleNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -188,6 +197,7 @@ internal object TokensDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, tokensFeatureToggles: TokensFeatureToggles, ): FetchCardTokenListUseCase { return FetchCardTokenListUseCase( @@ -196,6 +206,7 @@ internal object TokensDomainModule { quotesRepository = quotesRepository, stakingRepository = stakingRepository, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -364,10 +375,14 @@ internal object TokensDomainModule { fun provideRefreshMultiCurrencyWalletQuotesUseCase( currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, + multiQuoteFetcher: MultiQuoteFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): RefreshMultiCurrencyWalletQuotesUseCase { return RefreshMultiCurrencyWalletQuotesUseCase( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, + multiQuoteFetcher = multiQuoteFetcher, + tokensFeatureToggles = tokensFeatureToggles, ) } @@ -386,20 +401,26 @@ internal object TokensDomainModule { tokensFeatureToggles: TokensFeatureToggles, currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, + quotesRepositoryV2: QuotesRepositoryV2, networksRepository: NetworksRepository, stakingRepository: StakingRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, + singleQuoteSupplier: SingleQuoteSupplier, ): BaseCurrenciesStatusesOperations { return CachedCurrenciesStatusesOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, + quotesRepositoryV2 = quotesRepositoryV2, networksRepository = networksRepository, stakingRepository = stakingRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, multiNetworkStatusSupplier = multiNetworkStatusSupplier, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, + singleQuoteSupplier = singleQuoteSupplier, tokensFeatureToggles = tokensFeatureToggles, ) } @@ -410,21 +431,33 @@ internal object TokensDomainModule { tokensFeatureToggles: TokensFeatureToggles, currenciesRepository: CurrenciesRepository, quotesRepository: QuotesRepository, + quotesRepositoryV2: QuotesRepositoryV2, networksRepository: NetworksRepository, stakingRepository: StakingRepository, singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + multiQuoteFetcher: MultiQuoteFetcher, + singleQuoteSupplier: SingleQuoteSupplier, ): BaseCurrencyStatusOperations { return CachedCurrenciesStatusesOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, + quotesRepositoryV2 = quotesRepositoryV2, networksRepository = networksRepository, stakingRepository = stakingRepository, singleNetworkStatusSupplier = singleNetworkStatusSupplier, multiNetworkStatusSupplier = multiNetworkStatusSupplier, multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiQuoteFetcher = multiQuoteFetcher, + singleQuoteSupplier = singleQuoteSupplier, tokensFeatureToggles = tokensFeatureToggles, ) } + + @Provides + @Singleton + fun provideGetCryptoCurrenciesUseCase(currenciesRepository: CurrenciesRepository): GetCryptoCurrenciesUseCase { + return GetCryptoCurrenciesUseCase(currenciesRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index adc63cbd3d..3e76a02638 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -30,6 +30,15 @@ internal object TransactionDomainModule { ) } + @Provides + @Singleton + fun provideTransferGetFeeUseCase(walletManagersFacade: WalletManagersFacade): GetTransferFeeUseCase { + return GetTransferFeeUseCase( + walletManagersFacade = walletManagersFacade, + demoConfig = DemoConfig(), + ) + } + @Provides @Singleton fun provideSendTransactionUseCase( @@ -147,4 +156,30 @@ internal object TransactionDomainModule { fun provideGetAllowanceUseCase(transactionRepository: TransactionRepository): GetAllowanceUseCase { return GetAllowanceUseCase(transactionRepository) } + + @Provides + @Singleton + fun provideCreateTransferTransactionUseCase( + transactionRepository: TransactionRepository, + ): CreateTransferTransactionUseCase { + return CreateTransferTransactionUseCase(transactionRepository) + } + + @Provides + @Singleton + fun providePrepareForSendUseCase( + transactionRepository: TransactionRepository, + cardSdkConfigRepository: CardSdkConfigRepository, + ): PrepareForSendUseCase { + return PrepareForSendUseCase(transactionRepository, cardSdkConfigRepository) + } + + @Provides + @Singleton + fun provideSignUseCase( + walletManagersFacade: WalletManagersFacade, + cardSdkConfigRepository: CardSdkConfigRepository, + ): SignUseCase { + return SignUseCase(cardSdkConfigRepository, walletManagersFacade) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index 9be71f685e..4096521b75 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -14,6 +14,8 @@ import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSy import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.features.nft.NFTFeatureToggles +import com.tangem.operations.attestation.CardArtworksProvider +import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -172,8 +174,16 @@ internal object WalletsDomainModule { @Provides @Singleton - fun provideGetCardImageUseCase(onlineCardVerifier: OnlineCardVerifier): GetCardImageUseCase { - return GetCardImageUseCase(verifier = onlineCardVerifier) + fun provideGetCardImageUseCase( + onlineCardVerifier: OnlineCardVerifier, + cardArtworksProvider: CardArtworksProvider, + cardSdkFeatureToggles: CardSdkFeatureToggles, + ): GetCardImageUseCase { + return GetCardImageUseCase( + verifier = onlineCardVerifier, + cardArtworksProvider = cardArtworksProvider, + cardSdkFeatureToggles = cardSdkFeatureToggles, + ) } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt b/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt index 7117819925..547dfbd6ce 100644 --- a/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt +++ b/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt @@ -6,6 +6,7 @@ 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.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.operations.attestation.api.models.CardVerifyAndGetInfo @@ -37,7 +38,7 @@ suspend fun CardDTO.getOrLoadCardArtworkUrl( if (artworkId.isNullOrEmpty()) { ifAnyError() } else { - OnlineCardVerifier.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId) + CardArtworksProvider.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId) } } diff --git a/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt index 2927924f8c..adc8e80741 100644 --- a/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt +++ b/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt @@ -9,4 +9,10 @@ internal class DefaultTokensFeatureToggles( override val isNetworksLoadingRefactoringEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(name = "NETWORKS_LOADING_REFACTORING_ENABLED") + + override val isQuotesLoadingRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "QUOTES_LOADING_REFACTORING_ENABLED") + + override val isStakingLoadingRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "STAKING_LOADING_REFACTORING_ENABLED") } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt index e7a9f1873f..95e75741b7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt @@ -21,8 +21,6 @@ internal data class UserWalletPublicInformation( val name: String, @Json(name = "walletId") val walletId: UserWalletId, - @Json(name = "artworkUrl") - val artworkUrl: String, @Json(name = "cardsInWallet") val cardsInWallet: Set, @Json(name = "scanResponse") diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt index 9ee2498fa8..d963dc5794 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt @@ -20,9 +20,12 @@ internal class DefaultUserWalletsPublicInformationRepository( moshi: Moshi, private val secureStorage: SecureStorage, ) : UserWalletsPublicInformationRepository { - private val publicInformationAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java), - ) + + private val publicInformationAdapter: JsonAdapter> by lazy { + moshi.adapter( + Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java), + ) + } override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult { return withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt index 13c56f4f22..a1888ea494 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt @@ -24,12 +24,15 @@ internal class DefaultUserWalletsSensitiveInformationRepository( private val secureStorage: SecureStorage, ) : UserWalletsSensitiveInformationRepository { - private val sensitiveInformationAdapter: JsonAdapter = moshi.adapter( - UserWalletSensitiveInformation::class.java, - ) - private val encryptedSensitiveInformationMapAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java), - ) + private val sensitiveInformationAdapter: JsonAdapter by lazy { + moshi.adapter(UserWalletSensitiveInformation::class.java) + } + + private val encryptedSensitiveInformationMapAdapter: JsonAdapter> by lazy { + moshi.adapter( + Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java), + ) + } override suspend fun save(userWallet: UserWallet, encryptionKey: ByteArray?): CompletionResult { if (encryptionKey == null) { diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt index 4c73101416..8dca07bec0 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt @@ -15,7 +15,6 @@ internal val UserWallet.publicInformation: UserWalletPublicInformation get() = UserWalletPublicInformation( name = name, walletId = walletId, - artworkUrl = artworkUrl, cardsInWallet = cardsInWallet, isMultiCurrency = isMultiCurrency, scanResponse = scanResponse.copy( @@ -31,7 +30,6 @@ internal fun UserWalletPublicInformation.toUserWallet(): UserWallet { return UserWallet( name = name, walletId = walletId, - artworkUrl = artworkUrl, cardsInWallet = cardsInWallet, scanResponse = scanResponse, isMultiCurrency = isMultiCurrency, diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt index aa615456cc..ea5b87285c 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt @@ -4,8 +4,8 @@ import com.tangem.Message import com.tangem.blockchain.blockchains.ethereum.EthereumGasLoader import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumUtils -import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.CompiledSmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.* import com.tangem.blockchainsdk.utils.fromNetworkId @@ -16,6 +16,7 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic.TransactionSent.MemoType +import com.tangem.data.walletconnect.network.ethereum.LegacySdkHelper import com.tangem.operations.sign.SignHashCommand import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.safeUpdate @@ -101,7 +102,7 @@ class WalletConnectSdkHelper { sourceAddress = transaction.from, destinationAddress = destinationAddress, extras = EthereumTransactionExtras( - data = transaction.data.removePrefix(HEX_PREFIX).hexToBytes(), + callData = CompiledSmartContractCallData(transaction.data.removePrefix(HEX_PREFIX).hexToBytes()), gasLimit = gasLimit.toBigInteger(), nonce = transaction.nonce?.hexToBigDecimal()?.toBigInteger(), ), @@ -191,7 +192,7 @@ class WalletConnectSdkHelper { val gasLimitResult = (walletManager as? EthereumGasLoader)?.getGasLimit( amount = Amount(value, walletManager.wallet.blockchain), destination = transaction.to ?: "", - data = transaction.data, + callData = CompiledSmartContractCallData(transaction.data.hexToBytes()), ) return when (gasLimitResult) { is Result.Success -> gasLimitResult.data.toBigDecimal().multiply(BigDecimal("1.2")) @@ -357,36 +358,9 @@ class WalletConnectSdkHelper { ) } - private fun createMessageData(message: WcSignMessage): ByteArray { - val messageData = try { - message.data.removePrefix(HEX_PREFIX).hexToBytes() - } catch (exception: Exception) { - message.data.asciiToHex()?.hexToBytes() ?: byteArrayOf() - } + private fun createMessageData(message: WcSignMessage): ByteArray = LegacySdkHelper.createMessageData(message.data) - val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray() - return (prefixData + messageData).toKeccak() - } - - private fun String.hexToAscii(): String? { - return try { - removePrefix(HEX_PREFIX).hexToBytes() - .map { - val char = it.toInt().toChar() - if (char.isAscii()) char else return null - } - .joinToString("") - } catch (exception: Exception) { - return null - } - } - - private fun String.asciiToHex(): String? { - return map { - if (!it.isAscii()) return null - Integer.toHexString(it.code) - }.joinToString("") - } + private fun String.hexToAscii(): String? = LegacySdkHelper.hexToAscii(hex = this) suspend fun signPersonalMessage( hashToSign: ByteArray, @@ -535,7 +509,6 @@ class WalletConnectSdkHelper { "{\"signature\":\"$signature\",\"publicKey\":\"$publicKey\"}" private companion object { - const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n" const val HEX_PREFIX = "0x" const val DEFAULT_MAX_GASLIMIT = 350000 // TODO remove after [REDACTED_JIRA] diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt index 3fefa3a904..fe64265670 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt @@ -31,7 +31,7 @@ import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.onboarding.data.model.CreateWalletResponse import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource import com.tangem.feature.wallet.presentation.wallet.domain.BackupValidator -import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.CreateProductWalletTaskResponse import com.tangem.sdk.extensions.localizedDescriptionRes @@ -250,7 +250,7 @@ private suspend fun loadArtworkForCard(cardId: String, cardPublicKey: ByteArray, if (artworkId.isNullOrEmpty()) { defaultArtwork ?: Uri.EMPTY } else { - Uri.parse(OnlineCardVerifier.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)) + Uri.parse(CardArtworksProvider.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)) } } is Result.Failure -> defaultArtwork ?: Uri.EMPTY diff --git a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt index 621ce6f4e9..396fc681f2 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -3,6 +3,7 @@ package com.tangem.tap.proxy import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.blockchains.optimism.EthereumOptimisticRollupWalletManager import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.extensions.Result @@ -43,7 +44,7 @@ class TransactionManagerImpl( currencyToSend: Currency, destinationAddress: String, increaseBy: Int?, - data: String?, + callData: SmartContractCallData?, derivationPath: String?, ): ProxyFees { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } @@ -54,7 +55,7 @@ class TransactionManagerImpl( walletManager = walletManager, amount = amountToSend, destinationAddress = destinationAddress, - data = data, + callData = callData, ) } return getFeeForEthereumBlockchain( @@ -62,7 +63,7 @@ class TransactionManagerImpl( blockchain = blockchain, amountToSend = amountToSend, destinationAddress = destinationAddress, - data = data, + callData = callData, increaseBy = increaseBy, ) } else { @@ -160,14 +161,14 @@ class TransactionManagerImpl( blockchain: Blockchain, amountToSend: Amount, destinationAddress: String, - data: String?, + callData: SmartContractCallData?, increaseBy: Int?, ): ProxyFees { val gasLimit = getGasLimit( evmWalletManager = walletManager, amount = amountToSend, destinationAddress = destinationAddress, - data = data, + callData = callData, ).increaseBigIntegerByPercents(increaseBy) return when (val gasPrice = walletManager.getGasPrice()) { is Result.Success -> { @@ -183,16 +184,16 @@ class TransactionManagerImpl( walletManager: EthereumOptimisticRollupWalletManager, amount: Amount, destinationAddress: String, - data: String?, + callData: SmartContractCallData?, ): ProxyFees { - val fee = if (data.isNullOrEmpty()) { + val fee = if (callData == null) { walletManager.getFee(amount, destinationAddress) } else { - walletManager.getFee(amount, destinationAddress, data) + walletManager.getFee(amount, destinationAddress, callData) } return when (fee) { is Result.Success -> { - val choosableFee = fee.data + val choosableFee = fee.data as? TransactionFee.Choosable ?: error("Incorrect fee type") val minProxyFee = ProxyFee.Common( gasLimit = (choosableFee.minimum as Fee.Ethereum).gasLimit, @@ -223,9 +224,9 @@ class TransactionManagerImpl( evmWalletManager: EthereumWalletManager, amount: Amount, destinationAddress: String, - data: String?, + callData: SmartContractCallData?, ): BigInteger { - val result = if (data.isNullOrEmpty()) { + val result = if (callData == null) { evmWalletManager.getGasLimit( amount = amount, destination = destinationAddress, @@ -234,7 +235,7 @@ class TransactionManagerImpl( evmWalletManager.getGasLimit( amount = amount, destination = destinationAddress, - data = data, + callData = callData, ) } when (result) { diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index 2525bdd3cd..9d8ccb0fbb 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -32,6 +32,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.onramp.OnrampFeatureToggles +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository @@ -76,5 +77,6 @@ data class DaggerGraphState( val settingsManager: SettingsManager? = null, val uiMessageSender: UiMessageSender? = null, val onlineCardVerifier: OnlineCardVerifier? = null, + val cardArworksProvider: CardArtworksProvider? = null, val userWalletBuilderFactory: UserWalletBuilder.Factory? = null, ) : StateType \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 3c0ac636bb..362ae78fb2 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -12,13 +12,12 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.markets.details.MarketsTokenDetailsComponent -import com.tangem.features.nft.component.NFTCollectionsComponent -import com.tangem.features.nft.component.NFTDetailsComponent -import com.tangem.features.nft.component.NFTReceiveComponent +import com.tangem.features.nft.component.* import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent import com.tangem.features.onramp.component.* import com.tangem.features.pushnotifications.api.PushNotificationsComponent import com.tangem.features.send.api.SendComponent +import com.tangem.features.send.v2.api.NFTSendComponent import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.staking.api.StakingComponent import com.tangem.features.swap.SwapComponent @@ -83,9 +82,8 @@ internal class ChildFactory @Inject constructor( private val sendComponentFactoryV2: com.tangem.features.send.v2.api.SendComponent.Factory, private val sendFeatureToggles: SendFeatureToggles, private val redesignedWalletConnectComponentFactory: RedisegnedWalletConnectComponent.Factory, - private val nftCollectionsComponentFactory: NFTCollectionsComponent.Factory, - private val nftReceiveComponentFactory: NFTReceiveComponent.Factory, - private val nftDetailsComponentFactory: NFTDetailsComponent.Factory, + private val nftComponentFactory: NFTComponent.Factory, + private val nftSendComponentFactory: NFTSendComponent.Factory, private val testerRouter: TesterRouter, private val routingFeatureToggles: RoutingFeatureToggles, private val walletConnectFeatureToggles: WalletConnectFeatureToggles, @@ -405,24 +403,23 @@ internal class ChildFactory @Inject constructor( componentFactory = walletComponentFactory, ) } - is AppRoute.NFTCollections -> + is AppRoute.NFT -> createComponentChild( context = context, - params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId), - componentFactory = nftCollectionsComponentFactory, + params = NFTComponent.Params(userWalletId = route.userWalletId), + componentFactory = nftComponentFactory, ) - is AppRoute.NFTReceive -> + is AppRoute.NFTSend -> { createComponentChild( context = context, - params = NFTReceiveComponent.Params(userWalletId = route.userWalletId), - componentFactory = nftReceiveComponentFactory, - ) - is AppRoute.NFTDetails -> - createComponentChild( - context = context, - params = NFTDetailsComponent.Params(userWalletId = route.userWalletId, nftAsset = route.nftAsset), - componentFactory = nftDetailsComponentFactory, + params = NFTSendComponent.Params( + userWalletId = route.userWalletId, + nftAsset = route.nftAsset, + nftCollectionName = route.nftCollectionName, + ), + componentFactory = nftSendComponentFactory, ) + } is AppRoute.OnboardingNote, is AppRoute.SaveWallet, is AppRoute.OnboardingOther, @@ -757,24 +754,23 @@ internal class ChildFactory @Inject constructor( componentFactory = storiesComponentFactory, ) } - is AppRoute.NFTCollections -> + is AppRoute.NFT -> route.asComponentChild( contextProvider = contextProvider(route, contextFactory), - params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId), - componentFactory = nftCollectionsComponentFactory, + params = NFTComponent.Params(userWalletId = route.userWalletId), + componentFactory = nftComponentFactory, ) - is AppRoute.NFTReceive -> + is AppRoute.NFTSend -> { route.asComponentChild( contextProvider = contextProvider(route, contextFactory), - params = NFTReceiveComponent.Params(userWalletId = route.userWalletId), - componentFactory = nftReceiveComponentFactory, - ) - is AppRoute.NFTDetails -> - route.asComponentChild( - contextProvider = contextProvider(route, contextFactory), - params = NFTDetailsComponent.Params(userWalletId = route.userWalletId, nftAsset = route.nftAsset), - componentFactory = nftDetailsComponentFactory, + params = NFTSendComponent.Params( + userWalletId = route.userWalletId, + nftAsset = route.nftAsset, + nftCollectionName = route.nftCollectionName, + ), + componentFactory = nftSendComponentFactory, ) + } } // endregion } diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt index c66581f06d..7b9bd90813 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt @@ -39,7 +39,6 @@ internal class DefaultDerivationsRepositoryTest { private val defaultUserWallet = UserWallet( name = "", walletId = defaultUserWalletId, - artworkUrl = "", cardsInWallet = setOf(), isMultiCurrency = false, scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()), diff --git a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt index 8180ca85ab..209dcbb443 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt @@ -194,7 +194,6 @@ internal class BiometricUserWalletsListManagerTest(private val model: Model) { return UserWallet( name = "Wallet $id", walletId = UserWalletId(stringValue = id), - artworkUrl = "", cardsInWallet = emptySet(), isMultiCurrency = true, hasBackupError = false, diff --git a/build.gradle.kts b/build.gradle.kts index 1b240b2cfd..4cbeef6686 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,9 +59,41 @@ val assembleInternalQA by tasks.registering { } } +val assembleExternalQA by tasks.registering { + group = "build" + description = "Builds external APK to 'build/outputs' directory" + + val appOutputApkDir = "$projectDir/app/build/outputs/apk/external" + val rootOutputApkDir = "$buildDir/outputs" + val injected = objects.newInstance() + + dependsOn(":app:assembleExternal") + + doFirst { + injected.fs.delete { + delete(appOutputApkDir) + delete("$rootOutputApkDir/app-external.apk") + } + } + doLast { + injected.fs.copy { + from("$appOutputApkDir/app-external.apk") + into(rootOutputApkDir) + } + } +} + +val assembleQA by tasks.registering { + group = "build" + description = "Builds internal and external APKs to 'build/outputs' directory" + + dependsOn(assembleInternalQA) + dependsOn(assembleExternalQA) +} + val generateComposeMetrics by tasks.registering { group = "other" - description = "Build internal APK and generates compose metrics to 'build/compose-metrics' directory" + description = "Build external APK and generates compose metrics to 'build/compose-metrics' directory" subprojects { tasks.withType { @@ -86,5 +118,5 @@ val generateComposeMetrics by tasks.registering { } } - finalizedBy(assembleInternalQA) + finalizedBy(assembleExternalQA) } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index cc4bda0a08..508661dac0 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -281,18 +281,14 @@ sealed class AppRoute(val path: String) : Route { ) : AppRoute(path = "/stories$storyId") @Serializable - data class NFTCollections( + data class NFT( val userWalletId: UserWalletId, - ) : AppRoute(path = "/nft_collections/${userWalletId.stringValue}") + ) : AppRoute(path = "/nft/${userWalletId.stringValue}") @Serializable - data class NFTReceive( - val userWalletId: UserWalletId, - ) : AppRoute(path = "/nft_receive/${userWalletId.stringValue}") - - @Serializable - data class NFTDetails( + data class NFTSend( val userWalletId: UserWalletId, val nftAsset: NFTAsset, - ) : AppRoute(path = "/nft_details/${userWalletId.stringValue}/${nftAsset.collectionId}/${nftAsset.id.stringValue}") + val nftCollectionName: String, + ) : AppRoute(path = "/send/nft/${userWalletId.stringValue}/$nftCollectionName/${nftAsset.id}") } \ No newline at end of file diff --git a/common/test/build.gradle.kts b/common/test/build.gradle.kts index 00c1510373..f2de491c8c 100644 --- a/common/test/build.gradle.kts +++ b/common/test/build.gradle.kts @@ -9,7 +9,11 @@ android { } dependencies { + implementation(projects.core.datasource) + implementation(projects.core.utils) + implementation(projects.data.common) + implementation(projects.data.staking) implementation(projects.domain.legacy) implementation(projects.domain.models) @@ -20,6 +24,7 @@ dependencies { implementation(projects.libs.blockchainSdk) implementation(deps.androidx.datastore) + implementation(deps.jodatime) implementation(deps.test.coroutine) implementation(tangemDeps.blockchain) diff --git a/common/test/src/main/java/com/tangem/common/test/data/quote/MockQuoteResponseFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/quote/MockQuoteResponseFactory.kt new file mode 100644 index 0000000000..2e3f56492e --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/quote/MockQuoteResponseFactory.kt @@ -0,0 +1,19 @@ +package com.tangem.common.test.data.quote + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +object MockQuoteResponseFactory { + + fun createSinglePrice(value: BigDecimal): QuotesResponse.Quote { + return QuotesResponse.Quote( + price = value, + priceChange24h = value, + priceChange1w = value, + priceChange30d = value, + ) + } +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt b/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt new file mode 100644 index 0000000000..ac570f6d81 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt @@ -0,0 +1,14 @@ +package com.tangem.common.test.data.quote + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.local.quote.converter.QuoteConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Quote + +fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): Quote { + return QuoteConverter(source = source).convert(value = mapOf(rawCurrencyId to this).entries.first()) +} + +fun Pair.toDomain(source: StatusSource = StatusSource.ACTUAL): Quote { + return QuoteConverter(source = source).convert(value = mapOf(this).entries.first()) +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldBalanceWrapperDTOFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldBalanceWrapperDTOFactory.kt new file mode 100644 index 0000000000..71ad65f3d2 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldBalanceWrapperDTOFactory.kt @@ -0,0 +1,59 @@ +package com.tangem.common.test.data.staking + +import com.tangem.data.staking.store.YieldsBalancesStore.StakingID +import com.tangem.datasource.api.stakekit.models.request.Address +import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO +import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.TokenDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +object MockYieldBalanceWrapperDTOFactory { + + val defaultStakingId = StakingID( + integrationId = "ton-ton-chorus-one-pools-staking", + address = "0x1", + ) + + fun createWithBalance(stakingId: StakingID = defaultStakingId): YieldBalanceWrapperDTO { + return YieldBalanceWrapperDTO( + addresses = Address(address = stakingId.address), + balances = listOf( + BalanceDTO( + groupId = "groupId", + type = BalanceDTO.BalanceTypeDTO.UNKNOWN, + amount = BigDecimal.ONE, + date = null, + pricePerShare = BigDecimal.ZERO, + pendingActions = listOf(), + pendingActionConstraints = null, + tokenDTO = TokenDTO( + name = "The-Open-Network", + network = NetworkTypeDTO.TON, + symbol = "TON", + decimals = 8, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + validatorAddress = null, + validatorAddresses = null, + providerId = null, + ), + ), + integrationId = stakingId.integrationId, + ) + } + + fun createWithEmptyBalance(stakingId: StakingID = defaultStakingId): YieldBalanceWrapperDTO { + return YieldBalanceWrapperDTO( + addresses = Address(address = stakingId.address), + balances = emptyList(), + integrationId = stakingId.integrationId, + ) + } +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt index 365267e1ba..4c16726aa0 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt @@ -23,7 +23,6 @@ object MockUserWalletFactory { return UserWallet( walletId = userWalletId, name = "Wallet 1", - artworkUrl = "", cardsInWallet = emptySet(), scanResponse = scanResponse, isMultiCurrency = scanResponse.cardTypesResolver.isMultiwalletAllowed(), diff --git a/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt b/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt index 537e01b8f3..81467b2b4b 100644 --- a/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt +++ b/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt @@ -1,19 +1,18 @@ package com.tangem.common.test.utils -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @OptIn(ExperimentalCoroutinesApi::class) -fun CoroutineScope.getEmittedValues(testScheduler: TestCoroutineScheduler, actual: Flow): List { +fun TestScope.getEmittedValues(flow: Flow): List { val values = mutableListOf() - launch(UnconfinedTestDispatcher(testScheduler)) { - actual.toList(values) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + flow.toList(values) } return values diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt index c4a34ce6ee..b0d7438548 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.CardColors import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -61,7 +61,7 @@ fun UserWalletItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - CardImage(imageUrl = state.imageUrl) + CardImage(state.imageState) NameAndInfo( modifier = Modifier.weight(1f), name = state.name, @@ -147,39 +147,49 @@ private fun NameAndInfo( } @Composable -private fun CardImage(imageUrl: String, modifier: Modifier = Modifier) { +private fun CardImage(imageState: UserWalletItemUM.ImageState, modifier: Modifier = Modifier) { val imageModifier = modifier .width(TangemTheme.dimens.size24) .height(TangemTheme.dimens.size36) .clip(TangemTheme.shapes.roundedCornersSmall) - SubcomposeAsyncImage( - modifier = imageModifier, - model = ImageRequest.Builder(LocalContext.current) - .transformations(RotationTransformation(angle = 90f)) - .size( - width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }, - height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() }, - ) - .data(imageUrl) - .crossfade(enable = true) - .allowHardware(enable = false) - .build(), - loading = { + when (imageState) { + is UserWalletItemUM.ImageState.Loading -> { RectangleShimmer( modifier = imageModifier, radius = TangemTheme.dimens.size2, ) - }, - error = { - Image( + } + is UserWalletItemUM.ImageState.Image -> { + SubcomposeAsyncImage( modifier = imageModifier, - imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36), + model = ImageRequest.Builder(LocalContext.current) + .transformations(RotationTransformation(angle = 90f)) + .size( + width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }, + height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() }, + ) + .data(imageState.artwork.verifiedArtwork?.toByteArray() ?: imageState.artwork.defaultUrl) + .crossfade(enable = true) + .allowHardware(enable = false) + .build(), + loading = { + RectangleShimmer( + modifier = imageModifier, + radius = TangemTheme.dimens.size2, + ) + }, + error = { + Image( + modifier = imageModifier, + imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36), + contentDescription = null, + ) + }, contentDescription = null, ) - }, - contentDescription = null, - ) + } + } } @Composable @@ -215,7 +225,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider { override fun convert(value: UserWallet): UserWalletItemUM { @@ -40,10 +43,12 @@ class UserWalletItemUMConverter( name = stringReference(name), information = getInfo(userWallet = this), balance = getBalanceInfo(userWallet = this), - imageUrl = artworkUrl, isEnabled = !isLocked, endIcon = endIcon, onClick = { onClick(value.walletId) }, + imageState = artwork?.let { + UserWalletItemUM.ImageState.Image(ArtworkUM(it.verifiedArtwork, it.defaultUrl)) + } ?: UserWalletItemUM.ImageState.Loading, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt index 04b6329ce9..a986ba5b62 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt @@ -1,5 +1,6 @@ package com.tangem.common.ui.userwallet.state +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.wallets.models.UserWalletId import javax.annotation.concurrent.Immutable @@ -10,7 +11,7 @@ data class UserWalletItemUM( val name: TextReference, val information: TextReference, val balance: Balance, - val imageUrl: String, + val imageState: ImageState = ImageState.Loading, val isEnabled: Boolean, val endIcon: EndIcon = EndIcon.None, val onClick: () -> Unit, @@ -36,4 +37,14 @@ data class UserWalletItemUM( val isFlickering: Boolean, ) : Balance() } + + @Immutable + sealed class ImageState { + + data object Loading : ImageState() + + data class Image( + val artwork: ArtworkUM, + ) : ImageState() + } } \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt index 5d8c3a6887..879b87a6f9 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt @@ -22,9 +22,35 @@ sealed class MainScreenAnalyticsEvent( ) // region Action Buttons feature - data class ButtonBuy(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent( + data class ButtonBuy( + val status: AnalyticsParam.Status, + val screenType: String? = null, + ) : MainScreenAnalyticsEvent( event = "Button - Buy", - params = mapOf(AnalyticsParam.STATUS to status.value), + params = buildMap { + put(AnalyticsParam.STATUS, status.value) + screenType?.let { put(AnalyticsParam.TYPE, it) } + }, + ) + + data object ButtonReceive : MainScreenAnalyticsEvent( + event = "Button - Receive", + ) + + data object LimitsClicked : MainScreenAnalyticsEvent( + event = "Limits Clicked", + ) + + data object NoticeBalancesInfo : MainScreenAnalyticsEvent( + event = "Notice - Balances Info", + ) + + data object NoticeLimitsInfo : MainScreenAnalyticsEvent( + event = "Notice - Limits Info", + ) + + data object ButtonExplore : MainScreenAnalyticsEvent( + event = "Button - Explore", ) data class ButtonSwap(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent( @@ -83,4 +109,9 @@ sealed class MainScreenAnalyticsEvent( params = mapOf(ERROR_CODE to errorCode), ) // endregion + + companion object { + const val VISA_TYPE = "Visa" + const val WALLET_TYPE = "Wallet" + } } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 86ddd48e06..109fa60297 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -43,6 +43,10 @@ "name": "NOTE_REFACTORING_ENABLED", "version": "5.23.0" }, + { + "name": "NEW_ARTWORK_LOADING", + "version": "5.24.0" + }, { "name": "NEW_ATTESTATION_ENABLED", "version": "undefined" @@ -62,5 +66,13 @@ { "name": "NETWORKS_LOADING_REFACTORING_ENABLED", "version": "5.23.0" + }, + { + "name": "QUOTES_LOADING_REFACTORING_ENABLED", + "version": "5.24.0" + }, + { + "name": "STAKING_LOADING_REFACTORING_ENABLED", + "version": "undefined" } ] diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index 750dbada43..7aca4511ea 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -32,7 +32,7 @@ internal class DevFeatureTogglesManager( val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull>( key = PreferencesKeys.FEATURE_TOGGLES_KEY, - ) ?: emptyMap() + ) ?: emptyMap() val localFeatureToggles = localTogglesStorage.toggles .associateToggles(currentVersion = versionProvider.get().orEmpty()) diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt index b1822c84d9..a4c3d6d7aa 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt @@ -2,6 +2,7 @@ package com.tangem.core.configtoggle.manager import android.annotation.SuppressLint import com.google.common.truth.Truth +import com.squareup.moshi.Moshi import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants import com.tangem.core.configtoggle.storage.ConfigToggle @@ -12,6 +13,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.Test @@ -24,7 +26,11 @@ import kotlin.collections.set internal class DevTogglesManagerTest { private val localTogglesStorage = mockk() - private val appPreferenceStore = mockk(relaxed = true) + private val appPreferenceStore = AppPreferencesStore( + moshi = Moshi.Builder().build(), + dispatchers = TestingCoroutineDispatcherProvider(), + preferencesDataStore = mockk(relaxed = true), + ) private val versionProvider = mockk() private val manager = DevFeatureTogglesManager( localTogglesStorage = localTogglesStorage, diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt index 4a8e6550b8..b2f5bc4be7 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt @@ -8,6 +8,7 @@ import com.squareup.moshi.Types import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.asset.reader.AssetReader +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.Test @@ -24,7 +25,11 @@ internal class LocalTogglesStorageTest { private val jsonAdapter = mockk>>() // Impossible to mockk AssetLoader because it implement inline functions - private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi) + private val assetLoader = AssetLoader( + assetReader = assetReader, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) private val storage = LocalTogglesStorage(assetLoader) diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 0efd84702a..ebde9b43cd 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { /** Chucker */ debugImplementation(deps.chucker) mockedImplementation(deps.chuckerStub) + externalImplementation(deps.chuckerStub) internalImplementation(deps.chuckerStub) releaseImplementation(deps.chuckerStub) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/BlockAidApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/BlockAidApi.kt new file mode 100644 index 0000000000..605bc8a391 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/BlockAidApi.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.api.common.blockaid + +import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse +import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface BlockAidApi { + + @POST("site/scan") + suspend fun scanDomain(@Body request: DomainScanRequest): DomainScanResponse + + @POST("evm/json-rpc/scan") + suspend fun scanJsonRpc(@Body request: EvmTransactionScanRequest): TransactionScanResponse + + @POST("solana/message/scan") + suspend fun scanSolanaMessage(@Body request: SolanaTransactionScanRequest): TransactionScanResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/DomainScanRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/DomainScanRequest.kt new file mode 100644 index 0000000000..e88eca28ed --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/DomainScanRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.common.blockaid.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DomainScanRequest( + @Json(name = "url") val url: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/EvmTransactionScanRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/EvmTransactionScanRequest.kt new file mode 100644 index 0000000000..fd5aeecfaf --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/EvmTransactionScanRequest.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.common.blockaid.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata + +@JsonClass(generateAdapter = true) +data class EvmTransactionScanRequest( + @Json(name = "chain") val chain: String, + @Json(name = "account_address") val accountAddress: String, + @Json(name = "method") val method: String, + @Json(name = "data") val data: RpcData, + @Json(name = "options") val options: List = listOf("simulation", "validation"), + @Json(name = "metadata") val metadata: TransactionMetadata, +) + +@JsonClass(generateAdapter = true) +data class RpcData( + @Json(name = "jsonrpc") val jsonrpc: String = "2.0", + @Json(name = "method") val method: String, + @Json(name = "params") val params: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/SolanaTransactionScanRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/SolanaTransactionScanRequest.kt new file mode 100644 index 0000000000..72ec506f01 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/request/SolanaTransactionScanRequest.kt @@ -0,0 +1,16 @@ +package com.tangem.datasource.api.common.blockaid.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata + +@JsonClass(generateAdapter = true) +data class SolanaTransactionScanRequest( + @Json(name = "encoding") val encoding: String = "base64", + @Json(name = "chain") val chain: String, + @Json(name = "method") val method: String, + @Json(name = "options") val options: List = listOf("simulation, validation"), + @Json(name = "metadata") val metadata: TransactionMetadata, + @Json(name = "account_address") val accountAddress: String, + @Json(name = "transactions") val transactions: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AccountSummaryResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AccountSummaryResponse.kt new file mode 100644 index 0000000000..e0188fa7dc --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AccountSummaryResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AccountSummaryResponse( + @Json(name = "assets_diffs") val assetsDiffs: List, + @Json(name = "exposures") val exposures: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AssetDiff.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AssetDiff.kt new file mode 100644 index 0000000000..645409ec89 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/AssetDiff.kt @@ -0,0 +1,25 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AssetDiff( + @Json(name = "asset_type") val assetType: String, + @Json(name = "asset") val asset: Asset, + @Json(name = "in") val inTransfer: List? = null, + @Json(name = "out") val outTransfer: List? = null, +) + +@JsonClass(generateAdapter = true) +data class Asset( + @Json(name = "chain_id") val chainId: Int? = null, + @Json(name = "logo_url") val logoUrl: String? = null, + @Json(name = "symbol") val symbol: String, +) + +@JsonClass(generateAdapter = true) +data class Transfer( + @Json(name = "value") val value: String, + @Json(name = "raw_value") val rawValue: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/DomainScanResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/DomainScanResponse.kt new file mode 100644 index 0000000000..b11e7a6c88 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/DomainScanResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DomainScanResponse( + @Json(name = "status") val status: String, + @Json(name = "is_malicious") val isMalicious: Boolean?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Exposure.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Exposure.kt new file mode 100644 index 0000000000..90038d41c4 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/Exposure.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Exposure( + @Json(name = "asset") val asset: Asset, + @Json(name = "spenders") val spenders: Map, +) + +@JsonClass(generateAdapter = true) +data class SpenderDetails( + @Json(name = "exposure") val exposure: List, + @Json(name = "is_approved_for_all") val isApprovedForAll: Boolean? = null, +) + +@JsonClass(generateAdapter = true) +data class ExposureDetail( + @Json(name = "value") val value: String, + @Json(name = "raw_value") val rawValue: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/SimulationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/SimulationResponse.kt new file mode 100644 index 0000000000..a62c20d307 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/SimulationResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SimulationResponse( + @Json(name = "status") val status: String, + @Json(name = "account_summary") val accountSummary: AccountSummaryResponse, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionMetadata.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionMetadata.kt new file mode 100644 index 0000000000..13f229ead5 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionMetadata.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TransactionMetadata( + @Json(name = "domain") val domain: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionScanResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionScanResponse.kt new file mode 100644 index 0000000000..9d45809013 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/TransactionScanResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TransactionScanResponse( + @Json(name = "validation") val validation: ValidationResponse, + @Json(name = "simulation") val simulation: SimulationResponse, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt new file mode 100644 index 0000000000..08e86a0697 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/blockaid/models/response/ValidationResponse.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.common.blockaid.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ValidationResponse( + @Json(name = "status") val status: String, + @Json(name = "result_type") val resultType: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 66924d6e19..78db8380c3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt @@ -27,6 +27,7 @@ sealed class ApiConfig { TangemVisaAuth, TangemVisa, TangemCardSdk, + BlockAid, } private fun initializeId(): ID { @@ -37,6 +38,7 @@ sealed class ApiConfig { is TangemVisaAuth -> ID.TangemVisaAuth is TangemVisa -> ID.TangemVisa is TangemCardSdk -> ID.TangemCardSdk + is BlockAid -> ID.BlockAid } } @@ -44,6 +46,7 @@ sealed class ApiConfig { internal const val DEBUG_BUILD_TYPE = "debug" internal const val INTERNAL_BUILD_TYPE = "internal" internal const val MOCKED_BUILD_TYPE = "mocked" + internal const val EXTERNAL_BUILD_TYPE = "external" internal const val RELEASE_BUILD_TYPE = "release" } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/BlockAid.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/BlockAid.kt new file mode 100644 index 0000000000..008cd9ef09 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/BlockAid.kt @@ -0,0 +1,27 @@ +package com.tangem.datasource.api.common.config + +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage +import com.tangem.utils.ProviderSuspend + +internal class BlockAid( + private val environmentConfigStorage: EnvironmentConfigStorage, +) : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD + + override val environmentConfigs = listOf( + createProdEnvironment(), + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.blockaid.io/v0/", + headers = buildMap { + environmentConfigStorage.getConfigSync().blockAidApiKey?.let { apiKey -> + put("X-API-KEY", ProviderSuspend { apiKey }) + } + put("accept", ProviderSuspend { "application/json" }) + put("content-type", ProviderSuspend { "application/json" }) + }, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt index 3ad70b9000..3babcef973 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt @@ -73,6 +73,7 @@ internal class Express( INTERNAL_BUILD_TYPE, MOCKED_BUILD_TYPE, -> ApiEnvironment.STAGE + EXTERNAL_BUILD_TYPE, RELEASE_BUILD_TYPE, -> ApiEnvironment.PROD else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index c109e5b91e..7b474dd442 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -140,6 +140,40 @@ interface TangemTechApi { @GET("stories/{story_id}") suspend fun getStoryById(@Path("story_id") storyId: String): ApiResponse + // region push notifications + @GET("notification/push_notifications_eligible_networks") + suspend fun getEligibleNetworksForPushNotifications(): ApiResponse> + + @POST("user-wallets/applications/") + suspend fun createApplicationId( + @Body + body: NotificationApplicationCreateBody, + ): ApiResponse + + @PATCH("user-wallets/applications/{application_id}") + suspend fun updatePushTokenForApplicationId( + @Path("application_id") applicationId: String, + @Body body: NotificationApplicationCreateBody, + ): ApiResponse + + @PATCH("user-wallets/wallets/{wallet_id}/notify") + suspend fun setNotificationsEnabled(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse + // endregion + + // region wallets + @PATCH("user-wallets/wallets/{wallet_id}") + suspend fun updateWallet(@Path("wallet_id") walletId: String, @Body body: WalletBody): ApiResponse + + @POST("user-wallets/wallets/create-and-connect-by-appuid/{application_id}") + suspend fun associateApplicationIdWithWallets( + @Path("application_id") applicationId: String, + @Body body: List, + ): ApiResponse + + @GET("user-wallets/wallets/{wallet_id}") + suspend fun getWalletById(@Path("wallet_id") walletId: String): ApiResponse + // endregion + companion object { val marketsQuoteFields = listOf( "price", diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CryptoNetworkResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CryptoNetworkResponse.kt new file mode 100644 index 0000000000..610606eae8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CryptoNetworkResponse.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CryptoNetworkResponse( + @Json(name = "id") val id: Int, + @Json(name = "networkId") val networkId: String, + @Json(name = "name") val name: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationCreateBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationCreateBody.kt new file mode 100644 index 0000000000..e28589eefa --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationCreateBody.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NotificationApplicationCreateBody( + @Json(name = "pushToken") val pushToken: String, + @Json(name = "platform") val platform: String, + @Json(name = "device") val device: String, + @Json(name = "systemVersion") val systemVersion: String, + @Json(name = "language") val language: String, + @Json(name = "timezone") val timezone: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationIdResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationIdResponse.kt new file mode 100644 index 0000000000..4a2191fc4e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationIdResponse.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NotificationApplicationIdResponse( + @Json(name = "uid") val appId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationUpdateBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationUpdateBody.kt new file mode 100644 index 0000000000..ff5d0863cb --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/NotificationApplicationUpdateBody.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class NotificationApplicationUpdateBody( + @Json(name = "pushToken") val pushToken: String, + @Json(name = "systemVersion") val systemVersion: String? = null, + @Json(name = "language") val language: String? = null, + @Json(name = "timezone") val timezone: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt new file mode 100644 index 0000000000..d7a5fa0b91 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletBody.kt @@ -0,0 +1,10 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WalletBody( + @Json(name = "notifyStatus") val notifyStatus: String? = null, + @Json(name = "name") val name: String? = null, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt new file mode 100644 index 0000000000..dbadddd6fc --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletIdBody.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WalletIdBody( + @Json(name = "id") val walletId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletResponse.kt new file mode 100644 index 0000000000..ad0ff4ebc6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/WalletResponse.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.api.tangemTech.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WalletResponse( + @Json(name = "notifyStatus") val notifyStatus: String? = null, + @Json(name = "name") val name: String? = null, + @Json(name = "id") val id: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt index e0ee6cc4f0..aeb498dac8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt @@ -7,10 +7,13 @@ import com.squareup.moshi.JsonClass data class CardActivationRemoteStateResponse( @Json(name = "activation_status") val status: String, @Json(name = "activation_order") val activationOrder: ActivationOrder?, + @Json(name = "stepChangeCode") val stepChangeCode: Int?, + @Json(name = "updatedAt") val updatedAt: String?, ) { @JsonClass(generateAdapter = true) data class ActivationOrder( @Json(name = "id") val id: String, + @Json(name = "customer_id") val customerId: String, @Json(name = "customer_wallet_address") val customerWalletAddress: String, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt new file mode 100644 index 0000000000..5f9e2c12a9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.appcurrency + +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import kotlinx.coroutines.flow.Flow + +/** + * Store of app currency data model [CurrenciesResponse.Currency] + * +[REDACTED_AUTHOR] + */ +interface AppCurrencyResponseStore { + + /** Get flow of [CurrenciesResponse.Currency] */ + fun get(): Flow + + /** Get [CurrenciesResponse.Currency] synchronously or null */ + suspend fun getSyncOrNull(): CurrenciesResponse.Currency? +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt new file mode 100644 index 0000000000..1c268c06c7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt @@ -0,0 +1,28 @@ +package com.tangem.datasource.appcurrency + +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObject +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import kotlinx.coroutines.flow.Flow + +/** + * Default implementation of [AppCurrencyResponseStore] + * + * @property appPreferencesStore app preferences store + */ +internal class DefaultAppCurrencyResponseStore( + private val appPreferencesStore: AppPreferencesStore, +) : AppCurrencyResponseStore { + + override fun get(): Flow { + return appPreferencesStore.getObject(PreferencesKeys.SELECTED_APP_CURRENCY_KEY) + } + + override suspend fun getSyncOrNull(): CurrenciesResponse.Currency? { + return appPreferencesStore.getObjectSyncOrNull( + PreferencesKeys.SELECTED_APP_CURRENCY_KEY, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt b/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt index 6c04362a3c..3f47419a51 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt @@ -3,8 +3,10 @@ package com.tangem.datasource.asset.loader import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.squareup.moshi.adapter +import com.tangem.utils.coroutines.runCatching import com.tangem.datasource.asset.reader.AssetReader import com.tangem.datasource.di.NetworkMoshi +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -23,69 +25,63 @@ import javax.inject.Singleton class AssetLoader @Inject constructor( val assetReader: AssetReader, @NetworkMoshi val moshi: Moshi, + val dispatchers: CoroutineDispatcherProvider, ) { - /** Load content [Content] of asset file [fileName] */ @OptIn(ExperimentalStdlibApi::class) - suspend inline fun load(fileName: String): Content? { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun load(fileName: String): Content? = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - moshi.adapter().fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - null - }, - ) + moshi.adapter().fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + null + }, + ) /** Load list [V] values of asset file [fileName] */ - suspend inline fun loadList(fileName: String): List { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun loadList(fileName: String): List = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - val type = Types.newParameterizedType(List::class.java, V::class.java) - val adapter = moshi.adapter>(type) + val type = Types.newParameterizedType(List::class.java, V::class.java) + val adapter = moshi.adapter>(type) - adapter.fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig.orEmpty() - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - emptyList() - }, - ) + adapter.fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig.orEmpty() + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + emptyList() + }, + ) /** Load map [String] keys and [V] values of asset file [fileName] */ - suspend inline fun loadMap(fileName: String): Map { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun loadMap(fileName: String): Map = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - adapter.fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig.orEmpty() - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - emptyMap() - }, - ) + adapter.fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig.orEmpty() + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + emptyMap() + }, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt b/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt index 139e082aa9..3847d29980 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt @@ -1,23 +1,19 @@ package com.tangem.datasource.asset.reader import android.content.res.AssetManager -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext import java.io.BufferedReader /** * Implementation of asset file reader * * @property assetManager asset manager - * @property dispatchers dispatchers */ internal class AndroidAssetReader( private val assetManager: AssetManager, - private val dispatchers: CoroutineDispatcherProvider, ) : AssetReader { - override suspend fun read(fullFileName: String): String = withContext(dispatchers.io) { - assetManager.open(fullFileName).bufferedReader() + override suspend fun read(fullFileName: String): String { + return assetManager.open(fullFileName).bufferedReader() .use(BufferedReader::readText) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 5035774b7f..1a90974c75 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -46,6 +46,12 @@ internal object ApiConfigsModule { @IntoSet fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemVisa(appVersionProvider) + @Provides + @IntoSet + fun provideBlockAidConfig(environmentConfigStorage: EnvironmentConfigStorage): ApiConfig { + return BlockAid(environmentConfigStorage) + } + @Provides @IntoSet fun provideTangemCardSdkConfig(): ApiConfig = TangemCardSdk() diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt index fade673123..70fb61dbea 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt @@ -1,8 +1,11 @@ package com.tangem.datasource.di +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.datasource.appcurrency.DefaultAppCurrencyResponseStore import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore import com.tangem.datasource.local.appcurrency.implementation.DefaultAvailableAppCurrenciesStore import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,4 +21,10 @@ internal object AppCurrencyDataModule { fun provideAvailableAppCurrenciesStore(): AvailableAppCurrenciesStore { return DefaultAvailableAppCurrenciesStore(dataStore = RuntimeDataStore()) } + + @Provides + @Singleton + fun provideAppCurrencyResponseStore(appPreferencesStore: AppPreferencesStore): AppCurrencyResponseStore { + return DefaultAppCurrencyResponseStore(appPreferencesStore) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt index bc295ea874..d572234840 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt @@ -2,7 +2,6 @@ package com.tangem.datasource.di import android.content.Context import com.squareup.moshi.Moshi -import com.tangem.datasource.local.* import com.tangem.datasource.local.preferences.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -26,6 +25,7 @@ internal object AppPreferencesStoreModule { return AppPreferencesStore( preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io), moshi = moshi, + dispatchers = dispatchers, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt index 196ec8d836..3075e3f403 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt @@ -3,7 +3,6 @@ package com.tangem.datasource.di import android.content.Context import com.tangem.datasource.asset.reader.AndroidAssetReader import com.tangem.datasource.asset.reader.AssetReader -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -17,10 +16,7 @@ internal object AssetReaderModule { @Singleton @Provides - fun providesAsserReader( - @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, - ): AssetReader { - return AndroidAssetReader(context.assets, dispatchers) + fun providesAsserReader(@ApplicationContext context: Context): AssetReader { + return AndroidAssetReader(context.assets) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index 700b510390..fad4677392 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -51,12 +51,14 @@ class MoshiModule { PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc") .withSubtype(NFTCollection.Identifier.EVM::class.java, "evm") .withSubtype(NFTCollection.Identifier.TON::class.java, "ton") + .withSubtype(NFTCollection.Identifier.Solana::class.java, "sol") .withDefaultValue(NFTCollection.Identifier.Unknown), ) .add( PolymorphicJsonAdapterFactory.of(NFTAsset.Identifier::class.java, "bc") .withSubtype(NFTAsset.Identifier.EVM::class.java, "evm") .withSubtype(NFTAsset.Identifier.TON::class.java, "ton") + .withSubtype(NFTAsset.Identifier.Solana::class.java, "sol") .withDefaultValue(NFTAsset.Identifier.Unknown), ) .addLast(KotlinJsonAdapterFactory()) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index dd14d5f63a..e05ca4cf1a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -4,6 +4,7 @@ import android.content.Context import com.squareup.moshi.Moshi import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.common.blockaid.BlockAidApi import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfigs import com.tangem.datasource.api.common.config.managers.ApiConfigsManager @@ -282,6 +283,29 @@ internal object NetworkModule { .create(T::class.java) } + @Provides + @Singleton + fun provideBlockAidApi( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, + apiConfigsManager: ApiConfigsManager, + appLogsStore: AppLogsStore, + ): BlockAidApi { + return createApi( + id = ApiConfig.ID.BlockAid, + moshi = moshi, + context = context, + apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, + clientBuilder = { + addInterceptor( + NetworkLogsSaveInterceptor(appLogsStore), + ).applyTimeoutAnnotations() + }, + ) + } + private inline fun createApi( id: ApiConfig.ID, moshi: Moshi, diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt index dbcf34e870..48e2d14670 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/QuotesStoreModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import android.content.Context +import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi @@ -26,21 +27,27 @@ internal object QuotesStoreModule { @Provides @Singleton - fun provideQuotesStore( + fun providePersistenceQuotesStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, - ): QuotesStore { - return DefaultQuotesStore( - persistenceStore = DataStoreFactory.create( - serializer = MoshiDataStoreSerializer( - moshi = moshi, - types = mapWithStringKeyTypes(), - defaultValue = emptyMap(), - ), - produceFile = { context.dataStoreFile(fileName = "quotes") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ): DataStore> { + return DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(), + defaultValue = emptyMap(), ), + produceFile = { context.dataStoreFile(fileName = "quotes") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + } + + @Provides + @Singleton + fun provideQuotesStore(persistenceStore: DataStore>): QuotesStore { + return DefaultQuotesStore( + persistenceStore = persistenceStore, runtimeStore = RuntimeSharedStore(), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index d4bc388d6d..9da0b616fc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -14,4 +14,5 @@ data class EnvironmentConfig( val express: ExpressModel? = null, val devExpress: ExpressModel? = null, val stakeKitApiKey: String? = null, + val blockAidApiKey: String? = null, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt index 02d7967f47..80f41f68b3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/EnvironmentConfigConverter.kt @@ -23,6 +23,7 @@ internal object EnvironmentConfigConverter : Converter maxSize) { - file.delete() + if (logFile.exists() && logFile.length() > maxSize) { + logFile.delete() } } } @@ -117,7 +121,7 @@ class AppLogsStore @Inject constructor( } private fun writeMessage(tag: String, vararg messages: String) { - BufferedWriter(FileWriter(file, true)).use { writer -> + BufferedWriter(FileWriter(logFile, true)).use { writer -> writer.append(formatter.print(DateTime.now())) writer.append(": $tag ") messages.forEach(writer::append) @@ -126,8 +130,8 @@ class AppLogsStore @Inject constructor( } private fun createFileIfNotExist() { - if (!file.exists()) { - runCatching { file.createNewFile() } + if (!logFile.exists()) { + runCatching { logFile.createNewFile() } .onFailure(Timber::e) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt index b6a045de7e..090e711d37 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt @@ -13,6 +13,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter NFTAsset.Identifier.TON( tokenAddress = value.tokenAddress, ) + is SdkNFTAsset.Identifier.Solana -> NFTAsset.Identifier.Solana( + tokenAddress = value.tokenAddress, + cnft = value.cnft, + ) is SdkNFTAsset.Identifier.Unknown -> NFTAsset.Identifier.Unknown } @@ -24,6 +28,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter SdkNFTAsset.Identifier.TON( tokenAddress = value.tokenAddress, ) + is NFTAsset.Identifier.Solana -> SdkNFTAsset.Identifier.Solana( + tokenAddress = value.tokenAddress, + cnft = value.cnft, + ) is NFTAsset.Identifier.Unknown -> SdkNFTAsset.Identifier.Unknown } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt index 98d8e0ee73..44f1df3f89 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt @@ -12,6 +12,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter NFTCollection.Identifier.TON( contractAddress = value.contractAddress, ) + is SdkNFTCollection.Identifier.Solana -> NFTCollection.Identifier.Solana( + collection = value.collection, + ) is SdkNFTCollection.Identifier.Unknown -> NFTCollection.Identifier.Unknown } @@ -22,6 +25,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter SdkNFTCollection.Identifier.TON( contractAddress = value.contractAddress, ) + is NFTCollection.Identifier.Solana -> SdkNFTCollection.Identifier.Solana( + collection = value.collection, + ) is NFTCollection.Identifier.Unknown -> SdkNFTCollection.Identifier.Unknown } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt index 905fe4f767..d30b6e20ef 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.squareup.moshi.Moshi import com.squareup.moshi.Types +import com.tangem.utils.coroutines.CoroutineDispatcherProvider /** * Application preferences store. @@ -19,6 +20,7 @@ import com.squareup.moshi.Types */ class AppPreferencesStore( val moshi: Moshi, + val dispatchers: CoroutineDispatcherProvider, private val preferencesDataStore: DataStore, ) : DataStore by preferencesDataStore { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt index 0369cf22cd..647ff92c89 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt @@ -5,23 +5,25 @@ import androidx.datastore.preferences.core.edit import com.squareup.moshi.JsonDataException import com.squareup.moshi.Types import com.tangem.datasource.local.preferences.AppPreferencesStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext /** Get flow of nullable data [T] by string [key] */ inline fun AppPreferencesStore.getObject(key: Preferences.Key): Flow { - val adapter = moshi.adapter(T::class.java) - return data.map { preferences -> - preferences[key]?.let { - try { - adapter.fromJson(it) - } catch (e: JsonDataException) { - null - } - } - }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter(T::class.java) + emitAll( + data.map { preferences -> + preferences[key]?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + null + } + } + }.distinctUntilChanged(), + ) + } } /** @@ -32,16 +34,19 @@ inline fun AppPreferencesStore.getObject(key: Preferences.Key AppPreferencesStore.getObject(key: Preferences.Key, default: T): Flow { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - return data.map { - try { - it[key]?.let(adapter::fromJson) ?: default - } catch (e: JsonDataException) { - default - } - }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter(T::class.java) + emitAll( + data.map { + try { + it[key]?.let(adapter::fromJson) ?: default + } catch (e: JsonDataException) { + default + } + }.distinctUntilChanged(), + ) + } } - /** * Get nullable data [T] by string [key] * @@ -49,26 +54,27 @@ inline fun AppPreferencesStore.getObject(key: Preferences.Key AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key): T? { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - return data.firstOrNull() - ?.get(key) - ?.let { - try { - adapter.fromJson(it) - } catch (e: JsonDataException) { - null +suspend inline fun AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key): T? = + withContext(dispatchers.io) { + val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types + data.firstOrNull() + ?.get(key) + ?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + null + } } - } -} + } /** Get data [T] by string [key]. If data is not found, it returns [default] */ suspend inline fun AppPreferencesStore.getObjectSyncOrDefault( key: Preferences.Key, default: T, -): T { +): T = withContext(dispatchers.io) { val adapter = moshi.adapter(T::class.java) - return data.firstOrNull() + data.firstOrNull() ?.get(key) ?.let { try { @@ -87,37 +93,47 @@ suspend inline fun AppPreferencesStore.getObjectSyncOrDefault( * * @see storeObjectList * */ -suspend inline fun AppPreferencesStore.storeObject(key: Preferences.Key, value: T) { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - edit { it[key] = adapter.toJson(value) } -} +@Suppress("OptionalUnit") +suspend inline fun AppPreferencesStore.storeObject(key: Preferences.Key, value: T): Unit = + withContext(dispatchers.io) { + val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types + edit { it[key] = adapter.toJson(value) } + } /** Store list of data [value] by string [key] */ -suspend inline fun AppPreferencesStore.storeObjectList(key: Preferences.Key, value: List) { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - edit { it[key] = adapter.toJson(value) } -} +suspend inline fun AppPreferencesStore.storeObjectList(key: Preferences.Key, value: List) = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + edit { it[key] = adapter.toJson(value) } + } /** Get flow of list of data [T] by string [key]. If data is not found, it returns `null` */ inline fun AppPreferencesStore.getObjectList(key: Preferences.Key): Flow?> { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - return data.map { it[key]?.let(adapter::fromJson) }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + emitAll( + data.map { + it[key]?.let(adapter::fromJson) + }.distinctUntilChanged(), + ) + } } /** Get list of data [T] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectListSync(key: Preferences.Key): List { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} +suspend inline fun AppPreferencesStore.getObjectListSync(key: Preferences.Key): List = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Store map with [String] key and value [V] by string [key] */ suspend inline fun AppPreferencesStore.storeObjectMap( key: Preferences.Key, value: Map, -) { +) = withContext(dispatchers.io) { val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) val adapter = moshi.adapter>(type) @@ -125,37 +141,47 @@ suspend inline fun AppPreferencesStore.storeObjectMap( } /** Get map with [String] key and value [V] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectMapSync(key: Preferences.Key): Map { - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) +suspend inline fun AppPreferencesStore.getObjectMapSync(key: Preferences.Key): Map = + withContext(dispatchers.io) { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Get flow of map with [String] key and value [V] by string [key], or empty if data is not found */ inline fun AppPreferencesStore.getObjectMap(key: Preferences.Key): Flow> { - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) + return flow { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - return data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() } + emitAll( + data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() }, + ) + } } /** Get set of data [T] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectSetSync(key: Preferences.Key): Set { - val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} +suspend inline fun AppPreferencesStore.getObjectSetSync(key: Preferences.Key): Set = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Get flow of set of [T] by string [key], or empty if data is not found */ inline fun AppPreferencesStore.getObjectSet(key: Preferences.Key): Flow> { - val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) - return data.map { - it[key]?.let(adapter::fromJson) ?: emptySet() + return flow { + val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) + emitAll( + data.map { + it[key]?.let(adapter::fromJson) ?: emptySet() + }, + ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt index a049816e5c..386887a13b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt @@ -22,7 +22,9 @@ internal class SharedPreferencesKeyMigration( private val keyName: String, ) : DataMigration { - private val legacyPrefs = context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE) + private val legacyPrefs by lazy { + context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE) + } override suspend fun cleanUp() { val sharedPrefsEditor = legacyPrefs.edit() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt index 120bea1730..54af10bcb2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt @@ -10,13 +10,24 @@ import com.tangem.utils.extensions.orZero /** * Converter from [QuotesResponse.Quote] to [Quote.Value] * - * @property isCached flag that determines whether the quote is a cache + * @property source status source * [REDACTED_AUTHOR] */ -internal class QuoteConverter(private val isCached: Boolean) : +class QuoteConverter( + private val source: StatusSource, +) : Converter, Quote.Value> { + /** + * Secondary constructor + * + * @param isCached flag that determines whether the quote is a cache + */ + constructor(isCached: Boolean) : this( + source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL, + ) + override fun convert(value: Map.Entry): Quote.Value { val (currencyId, quote) = value @@ -24,7 +35,7 @@ internal class QuoteConverter(private val isCached: Boolean) : rawCurrencyId = CryptoCurrency.RawID(currencyId), fiatRate = quote.price.orZero(), priceChange = quote.priceChange24h.orZero().movePointLeft(2), - source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL, + source = source, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/YieldBalanceConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/YieldBalanceConverter.kt index 051e63780e..de55373f39 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/YieldBalanceConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/YieldBalanceConverter.kt @@ -7,7 +7,7 @@ import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceItem import com.tangem.utils.converter.Converter -internal class YieldBalanceConverter( +class YieldBalanceConverter( private val source: StatusSource, ) : Converter { diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index d4158d4965..e0df7b4096 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -6,6 +6,7 @@ import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.config.* import com.tangem.datasource.api.common.config.ApiConfig.Companion.DEBUG_BUILD_TYPE +import com.tangem.datasource.api.common.config.ApiConfig.Companion.EXTERNAL_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfig.Companion.INTERNAL_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE @@ -90,6 +91,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { is TangemVisaAuth -> createVisaAuthModel() is TangemVisa -> createVisaModel() is TangemCardSdk -> createTangemCardSdkModel() + is BlockAid -> createBlockAidSdkModel() } } @@ -100,6 +102,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { INTERNAL_BUILD_TYPE, MOCKED_BUILD_TYPE, -> ApiEnvironment.STAGE + EXTERNAL_BUILD_TYPE, RELEASE_BUILD_TYPE, -> ApiEnvironment.PROD else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") @@ -115,6 +118,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { INTERNAL_BUILD_TYPE, MOCKED_BUILD_TYPE, -> "[REDACTED_ENV_URL]" + EXTERNAL_BUILD_TYPE, RELEASE_BUILD_TYPE, -> "https://express.tangem.com/v1/" else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") @@ -215,6 +219,16 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { ) } + private fun createBlockAidSdkModel(): Model { + return Model( + id = ApiConfig.ID.BlockAid, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.blockaid.io/v0/", + ), + ) + } + private fun String.checkHeaderValueOrEmpty(): String { for (i in this.indices) { val c = this[i] diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt index 49924b9fe8..1bbee64573 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt @@ -7,6 +7,7 @@ import com.squareup.moshi.Types import com.squareup.moshi.adapter import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.asset.reader.AssetReader +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery import io.mockk.coVerifyOrder import io.mockk.every @@ -22,7 +23,11 @@ class AssetLoaderTest { private val assetReader = mockk() private val moshi = mockk() - private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi) + private val assetLoader = AssetLoader( + assetReader = assetReader, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) @Test fun load() = runTest { diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt index c1beef670e..651d234c8d 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt @@ -2,7 +2,6 @@ package com.tangem.datasource.asset.reader import android.content.res.AssetManager import com.google.common.truth.Truth -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -15,7 +14,7 @@ import java.io.IOException internal class AndroidAssetReaderTest { private val assetManager = mockk() - private val assetReader = AndroidAssetReader(assetManager, TestingCoroutineDispatcherProvider()) + private val assetReader = AndroidAssetReader(assetManager) @Test fun read_content() = runTest { diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index b7a04e5add..7d9d38c56c 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -503,6 +503,12 @@ %d Stück %d Stücke + NFTs, die an Deine Wallet-Adresse gesendet werden, werden hier angezeigt. + Noch keine Kollektionen + NFT erhalten + NFT-Kollektionen + Einige Daten werden möglicherweise nicht geladen + Vorübergehende Ladeprobleme %1$d NFTs in der %2$d Sammlung Tippe hier, um das erste NFT zu erhalten NFT-Sammlungen @@ -522,7 +528,7 @@ Bitte wiederhole den Vorgang. Die Karte oder Ring wird auf Werkseinstellungen zurückgesetzt. Aktivierungsfehler Token hinzufügen - Du hast einee Backup-Karte oder einen Backup-Ring hinzugefügt. Wenn der Backup-Prozess abgeschlossen ist, kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du noch eine Karte oder einen Ring hast, fügen diese(n) zum Backup hinzu. Möchtest Du den Backup-Prozess fortsetzen? + Du hast eine Backup-Karte oder einen Backup-Ring hinzugefügt. Nach Abschluss des Backup-Vorgangs kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du eine weitere Karte oder einen weiteren Ring hast, fügen diesen dem Backup jetzt hinzu. Möchtest Du den Backup-Vorgang fortsetzen? Der Sicherungsvorgang ist teilweise abgeschlossen. Du kannst ihn jetzt nicht beenden. Die Passphrase ist eine fortschrittliche Sicherheitsfunktion, die von Krypto-Wallets verwendet wird. Sie fügt ein zusätzliches Wort oder eine Phrase deiner Wahl zu der bereits bestehenden Wiederherstellungsphrase hinzu, um einen brandneuen Satz von Adressen zu erzeugen. Hinzufügen einer Sicherungskarte oder Ring @@ -1165,6 +1171,7 @@ Der Kontostand ist möglicherweise veraltet. Aktualisiere bittre die Seite. Verbindungen Alle trennen + Text über die Trennung aller dApps Alle dApps trennen Neue Verbindung Verbinde Deine Wallet mit einer anderen dApp diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 01f05ff01c..47b464f247 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -140,6 +140,7 @@ Marché Lent Vitesse et frais + Terminer Synchroniser les adresses Aller au fournisseur Aller au jeton @@ -159,6 +160,7 @@ Aucune adresse Maintenant OK + Ouvrir dans le navigateur Carte principale Bague principale Passphrase @@ -182,6 +184,7 @@ Envoyer Le serveur n\'est pas disponible, veuillez réessayer plus tard Partager + Partager le lien Signez Signez et envoyez Stake @@ -496,6 +499,16 @@ Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché Ajouter des jetons NFC n\'est pas disponible sur votre appareil + Les NFT envoyés à l\'adresse de votre portefeuille s\'afficheront ici. + Aucune collection pour le moment + Recevoir des NFT + Collections NFT + Certaines données peuvent ne pas se charger + Problèmes de chargement temporaires + %1$d NFT dans la collection %2$d + Appuyez ici pour recevoir le premier NFT + Collections NFT + Impossible de charger les données Vous devez définir un seul code d\'accès pour protéger tous vos appareils. Protéger Vous pourrez définir un code d\'accès individuel sur chaque carte plus tard @@ -762,6 +775,8 @@ y compris des frais de réseau de %1$s La transaction a été signée avec succès et envoyée au nœud de blockchain. Le solde du portefeuille sera mis à jour après un certain temps %1$s est un actif du réseau Tron. Pour calculer les frais et effectuer une transaction, déposez du Tron (TRX) sur votre compte. + Une balise de destination (mémo) est requise pour terminer cette transaction pour l\'adresse spécifiée. + Étiquette de destination requise Transaction envoyée Scannez la carte/ bague que vous souhaitez configurer. Oublier le portefeuille @@ -783,6 +798,7 @@ %s profit estimatif Cote du marché Métriques + Selon les règles du réseau %1$s, les réclamations sont possibles à partir de %2$s. Les montants ci-dessous seront crédités sur votre compte lors du déblocage. Minimum requis Aucune récompense à réclamer Réclamation de récompense @@ -820,11 +836,16 @@ Solde de staking faible Un minimum de %1$s %2$s est requis pour le re-staking. Veuillez recharger votre solde. Pas assez de %s + Solde insuffisant pour le staking + Un minimum de 3 ADA est requis pour le re-staking. Veuillez recharger votre solde. + ADA insuffisants + Le montant minimum requis pour le staking doit être supérieur à 5 ADA. Veuillez recharger votre solde pour commencer à staking. L\'option de staking n\'est actuellement pas disponible en raison des conditions du réseau. Veuillez réessayer plus tard. Le staking dans le réseau %1$s avec un nouveau validateur transférera automatiquement tous les fonds précédemment stakés vers ce validateur Réinvestissez vos récompenses gagnées dans le montant que vous avez staké, augmentant ainsi vos gains potentiels. L\'option de restaker vous permet de déplacer vos fonds d\'un validateur à un autre sans avoir besoin de les déstaker. Vous êtes sur le point de staker l\'intégralité de votre solde. Nous vous recommandons de laisser un petit montant pour couvrir les frais de réseau pour unstaking ou la réclamation des récompenses. + Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille. Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s. Vos fonds seront disponibles à l\'utilisation après la période de déblocage de 21 jours. La récompense sera retirée en même temps que vos fonds de déblocage. Vos fonds seront disponibles pour utilisation après la période de désengagement %s. @@ -853,6 +874,7 @@ Récompenses Stake verrouillé Staker plus + Lorsque vous stakez %1$s, la totalité de votre solde %2$s est stakeée. Tout dépôt supplémentaire de %2$s sur votre portefeuille Tangem sera également staké automatiquement. Montant staké Vous stakez %1$s et recevrez %2$s Appuyez pour déverrouiller @@ -887,6 +909,8 @@ Découvrez Tangem Échangez, achetez des NFT, faites des prêts et des dépôts dans plus de 100 services décentralisés différents Compatible avec Web 3.0 + Une transaction entrante d\'au moins de %1$s est requise pour continuer + Fonds insuffisants Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange. Échangez plus de jetons à de meilleurs taux directement dans votre portefeuille. Nouveau fournisseur d\'échange disponible ! @@ -926,6 +950,7 @@ Vous n\'avez pas de fonds à envoyer. Renflouez votre compte pour pouvoir envoyer des fonds à partir de celui-ci. Die Daten wurden noch nicht geladen. Dies kann einige Sekunden dauern. Bitte versuchen Sie es später noch einmal. Le service d\'échange %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options. + Le solde affiché peut être obsolète en raison de la mise en cache. La vente de fonds sera disponible une fois que la ou les transactions en attente dans le réseau %s seront terminées L\'envoi de fonds sera disponible une fois la ou les transactions en attente dans le réseau %s terminées. L\'achat de %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options. @@ -972,6 +997,7 @@ Tangem Twin Cette action est irréversible. Vous n\'aurez plus accès à l\'ancien portefeuille. Appuyez sur la carte jumelle avec le numéro %s et ne la retirez pas jusqu\'à la fin de l\'opération + Nous avons rencontré une erreur. Code d\'erreur : %s. Veuillez contacter notre équipe de support. Utilisez %s ou scannez une carte/bague pour avoir accès à votre portefeuille Échec de la connexion : Cette dApp utilise Wallet Connect version1.0, qui n\'est pas prise en charge. Veuillez vous assurer que la dApp prend en charge Wallet Connect version2.0 pour réussir la connexion. Restez à jour avec les dernières fonctionnalités et actualités @@ -986,6 +1012,24 @@ Renommer le portefeuille Tout déverrouiller Tout déverrouiller avec %s + + disponible pour %d jour + disponible pour %d jours + + Soldes et Limites + Êtes-vous sûr de vouloir quitter ? Vous pourrez reprendre plus tard là où vous vous étiez arrêté. + Cela ne prendra pas longtemps. Nous configurons votre compte. + Cela ne prendra pas longtemps. Nous terminons l\'activation. + Tout est en cours de préparation ! + Code PIN invalide : évitez les séquences ou les répétitions + Accéder le site Web + Continuons la configuration de votre compte. + Content de vous revoir ! + Suivez les étapes pour configurer votre compte. + Bienvenue ! + Déverrouiller + Scannez votre carte pour déverrouiller l\'accès + Déverrouillage nécessaire La blockchain n\'est pas accessible. Réessayez plus tard Scanner la carte ou la bague Ce portefeuille a déjà été activé auparavant.\nSi cela n\'a pas été fait par vous, veuillez contacter le support.\nTangem ne vend jamais de portefeuilles avec le code d\'accès pré-généré. @@ -1116,6 +1160,13 @@ Il s\'agit d\'une carte Testnet. Elle ne peut pas traiter les transactions et ne doit être utilisée qu\'à des fins de test et de développement. À des fins de test uniquement Le solde peut être obsolète. Rafraîchissez la page. + Connexions + Déconnecter tout + Texte sur la déconnexion de toutes les dApps + Déconnecter toutes les dApps + Nouvelle connexion + Connectez votre portefeuille à différentes dApps + Aucune séance Ignorer Vous avez une sauvegarde interrompue. Voulez-vous la reprendre ? Oui, reprendre diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 9b67c45675..b59492d1ed 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -109,6 +109,8 @@ 報酬を受け取る 閉じる 確認 + Tangemサポートへ問い合わせる + Visaサポートへ問い合わせる 続ける コピー アドレスをコピー @@ -153,6 +155,7 @@ ネットワーク手数料 送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。 + NFT いいえ アドレスがありません @@ -175,6 +178,7 @@ 検索 トークンを検索 + すべて見る シードフレーズ アクションを選択 売る @@ -502,6 +506,20 @@ NFTコレクション 一部のデータが読み込まれない場合があります 一時的な読み込みの問題 + 基本情報 + チェーン + コントラクトアドレス + 最終販売価格 + レアリティ・ラベル + レアリティ・ランク + トークンアドレス + トークンID + トークン標準 + 特徴 + 結果がありません。別のリクエストをお試しください。 + ネットワークを選択 + 私のウォレットへ + NFTを受け取る %1$dコレクションの%2$dNFT ここをタップして最初のNFTを受け取ります NFTコレクション @@ -635,6 +653,7 @@ カメラへのアクセスが拒否されました メモ不要 %3$sネットワーク上の%1$s ( %2$s ) + %2$sネットワーク上の%1$s 他の暗号資産を送信すると、取り返しのつかない損失が発生します。 このアドレスには%sのみを送金してください。他のトークンを送信すると、取り返しのつかない損失が発生します。 %2$sネットワークの%1$sのみを送信してください @@ -1015,12 +1034,14 @@ 長くはかかりません。アカウントを設定しています。 長くはかかりません。アクティベーションを完了しています。 準備完了です! + PINの認証に失敗しました。もう一度お試しいただくか、別のコードを使用してください。 無効な暗証番号:連続や繰り返しを避けてください ウェブサイトに移動 アカウントの設定を続けましょう。 お帰りなさい! 手順に従ってアカウントを設定してください。 ようこそ! + この取引に異議を唱える ロック解除 カードをスキャンしてアクセスロックを解除する ロック解除が必要 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index de090f81f6..3ea8b2da0c 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1020,6 +1020,7 @@ Переименование кошелька Разблокировать все Разблокировать все с %s + ПИН не принят. Попробуйте ещё раз или введите другой код. Блокчейн недоступен. Попробуйте позже. Отсканируйте карту или кольцо Этот кошелек уже был активирован ранее.\nЕсли это сделали не вы, свяжитесь со службой поддержки.\nTangem никогда не продает кошелек вместе с предустановленным кодом доступа. diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index a23e1b705f..42b9c6762c 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -110,13 +110,14 @@ Claim rewards Close Confirm + Contact Tangem Support + Contact Visa Support Continue Copy Copy address Create %1$s (%2$s) Custom - NFT %d day %d days @@ -157,6 +158,7 @@ Network fee Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level Next + NFT No No address Now @@ -502,6 +504,7 @@ Pull this up or tap the search bar to add tokens directly from the market Add tokens NFC is not available on your device + About NFT %d item %d items @@ -512,24 +515,26 @@ NFT collections Some data may not load Temporary loading problems + Base information + Chain + Contract Address + Last sale price + Rarity label + Rarity rank + Token Address + Token ID + Token Standard + Traits No results. Please try another request. - Receive NFT + Choose network To My wallet + Receive NFT + Traits %1$d NFTs in %2$d collection Tap here to receive first NFT NFT collections Unable to load the data - Choose network - Last sale price - Rarity label - Rarity rank - Traits - Base information - Token Standard - Contract Address - Token ID - Token Address - Chain + No collection Set up a single access code to protect all your devices. Protect Set an individual access code for each card or ring later. @@ -1067,6 +1072,7 @@ Set up a 4-digit code. 
It will be used for payments. PIN code Create PIN Code + PIN was not accepted. Try again or use a different code. Invalid PIN: avoid sequences or repeats You\'re good to go! Prepare the Tangem card and tap to approve @@ -1098,6 +1104,7 @@ Transaction request Transaction status Type + Dispute this transaction Unlock Scan your card to unlock access Needed unlock @@ -1231,13 +1238,41 @@ This is a Testnet card. It cannot process transactions and should only be used for testing and development purposes. For testing purposes only Balance may be outdated. Refresh the page. + Unknown domain + Connect anyway + Timeout error. Please, try again later. + Failed to establish WalletConnect + This domain cannot be verified. Check the request carefully approving. + Go back to your browser and connect via WalletConnect again. + WalletConnect session was disconnected + Error code: %s. If the problem persists — feel free to contact our support. + We\'ve encountered unknown error + Tangem does not currently support a required network by %s. + Unsuported networks + Tangem support a required network by %s. + Verified domain + Wrong card or ring selected in Tangem App + We\'ve got some kind of problem + Connect + Network + Networks + Wallet + View your wallet balance and activity + Sign transactions without your notice + Request approval for transactions + Will not be able to + Would like to + Connection request Connections Disconnect all Text about discnected all dApps Disconect All dApps + Add the %s network to your profile for this wallet + The wallet has no required networks New connection Connect your wallet to a different dApps No sessions + Wallet connect Discard You have an interrupted backup. Do you want to resume? Yes, resume diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/artwork/ArtworkUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/artwork/ArtworkUM.kt new file mode 100644 index 0000000000..5c1f568b02 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/artwork/ArtworkUM.kt @@ -0,0 +1,17 @@ +package com.tangem.core.ui.components.artwork + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Immutable +data class ArtworkUM( + val verifiedArtwork: ImmutableList? = null, + val defaultUrl: String, +) { + + constructor(bytes: ByteArray?, defaultUrl: String) : this( + verifiedArtwork = bytes?.toList()?.toImmutableList(), + defaultUrl = defaultUrl, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt new file mode 100644 index 0000000000..4f071c2b9d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt @@ -0,0 +1,485 @@ +package com.tangem.core.ui.components.atoms.text + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp + +/** + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +private const val READ_MORE_TAG = "read_more" +private const val READ_LESS_TAG = "read_less" + +/** + * Basic element that displays text with read more. + * + * @param text The text to be displayed. + * @param expanded whether this text is expanded or collapsed. + * @param modifier [Modifier] to apply to this layout node. + * @param onExpandRequested called when this text is clicked. If `null`, then this text will not be + * interactable, unless something else handles its input events and updates its state. + * @param contentPadding a padding around the text. + * @param style Style configuration for the text such as color, font, line height etc. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw selection around the text. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [readMoreOverflow] and TextAlign may have unexpected effects. + * @param readMoreText The read more text to be displayed in the collapsed state. + * @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if + * necessary. If the text exceeds the given number of lines, it will be truncated according to + * [readMoreOverflow]. If it is not null, then it must be greater than zero. + * @param readMoreOverflow How visual overflow should be handled in the collapsed state. + * @param readMoreStyle Style configuration for the read more text such as color, font, line height + * etc. + * @param readLessText The read less text to be displayed in the expanded state. + * @param readLessStyle Style configuration for the read less text such as color, font, line height + * etc. + * @param toggleArea A clickable area of text to toggle. + */ +@Composable +fun ReadMoreText( + text: String, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + ReadMoreTextInternal( + text = AnnotatedString(text), + expanded = expanded, + modifier = modifier, + onExpandRequested = onExpandRequested, + contentPadding = contentPadding, + style = style, + onTextLayout = onTextLayout, + softWrap = softWrap, + readMoreText = readMoreText, + readMoreMaxLines = readMoreMaxLines, + readMoreOverflow = readMoreOverflow, + readMoreStyle = readMoreStyle, + readLessText = readLessText, + readLessStyle = readLessStyle, + toggleArea = toggleArea, + ) +} + +/** + * Basic element that displays text with read more. + * + * @param text The text to be displayed. + * @param expanded whether this text is expanded or collapsed. + * @param modifier [Modifier] to apply to this layout node. + * @param onExpandRequested called when this text is clicked. If `null`, then this text will not be + * interactable, unless something else handles its input events and updates its state. + * @param contentPadding a padding around the text. + * @param style Style configuration for the text such as color, font, line height etc. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw selection around the text. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [readMoreOverflow] and TextAlign may have unexpected effects. + * @param readMoreText The read more text to be displayed in the collapsed state. + * @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if + * necessary. If the text exceeds the given number of lines, it will be truncated according to + * [readMoreOverflow]. If it is not null, then it must be greater than zero. + * @param readMoreOverflow How visual overflow should be handled in the collapsed state. + * @param readMoreStyle Style configuration for the read more text such as color, font, line height + * etc. + * @param readLessText The read less text to be displayed in the expanded state. + * @param readLessStyle Style configuration for the read less text such as color, font, line height + * etc. + * @param toggleArea A clickable area of text to toggle. + */ +@Composable +fun ReadMoreText( + text: AnnotatedString, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + ReadMoreTextInternal( + text = text, + expanded = expanded, + modifier = modifier, + onExpandRequested = onExpandRequested, + contentPadding = contentPadding, + style = style, + onTextLayout = onTextLayout, + softWrap = softWrap, + readMoreText = readMoreText, + readMoreMaxLines = readMoreMaxLines, + readMoreOverflow = readMoreOverflow, + readMoreStyle = readMoreStyle, + readLessText = readLessText, + readLessStyle = readLessStyle, + toggleArea = toggleArea, + ) +} + +@Suppress("LongMethod", "LongParameterList") +@Composable +private fun ReadMoreTextInternal( + text: AnnotatedString, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + require(readMoreMaxLines > 0) { "readMoreMaxLines should be greater than 0" } + + val overflowText: String = remember(readMoreOverflow) { + buildString { + when (readMoreOverflow) { + ReadMoreTextOverflow.Clip -> { + } + ReadMoreTextOverflow.Ellipsis -> { + append(Typography.ellipsis) + } + } + if (readMoreText.isNotEmpty()) { + append(Typography.nbsp) + } + } + } + val readMoreTextWithStyle: AnnotatedString = remember(readMoreText, readMoreStyle) { + buildAnnotatedString { + if (readMoreText.isNotEmpty()) { + withStyle(readMoreStyle) { + append(readMoreText.replace(' ', Typography.nbsp)) + } + } + } + } + val readLessTextWithStyle: AnnotatedString = remember(readLessText, readLessStyle) { + buildAnnotatedString { + if (readLessText.isNotEmpty()) { + withStyle(readLessStyle) { + append(readLessText) + } + } + } + } + + val textMeasurer = rememberTextMeasurer() + val state = remember { ReadMoreState() } + + val currentText = buildAnnotatedString { + if (expanded) { + append(text) + if (readLessTextWithStyle.isNotEmpty()) { + append(' ') + if (toggleArea == ToggleArea.More) { + withLink( + LinkAnnotation.Clickable(tag = READ_LESS_TAG) { + onExpandRequested?.invoke(false) + }, + ) { + append(readLessTextWithStyle) + } + } else { + append(readLessTextWithStyle) + } + } + } else { + val collapsedText = state.collapsedText + if (collapsedText.isNotEmpty()) { + append(collapsedText) + append(overflowText) + + if (toggleArea == ToggleArea.More) { + withLink( + LinkAnnotation.Clickable(tag = READ_MORE_TAG) { + onExpandRequested?.invoke(true) + }, + ) { + append(readMoreTextWithStyle) + } + } else { + append(readMoreTextWithStyle) + } + } else { + append(text) + } + } + } + val toggleableModifier = if (onExpandRequested != null && toggleArea == ToggleArea.All) { + Modifier.clickable( + enabled = state.isCollapsible, + onClick = { onExpandRequested(!expanded) }, + ) + } else { + Modifier + } + BoxWithConstraints( + modifier = modifier + .then(toggleableModifier) + .padding(contentPadding), + ) { + BasicText( + text = currentText, + modifier = Modifier, + style = style, + onTextLayout = onTextLayout, + overflow = TextOverflow.Ellipsis, + softWrap = softWrap, + maxLines = if (expanded) Int.MAX_VALUE else readMoreMaxLines, + ) + + val constraints = Constraints(maxWidth = constraints.maxWidth) + LaunchedEffect( + textMeasurer, + constraints, + overflowText, + readMoreTextWithStyle, + style, + readMoreStyle, + text, + readMoreMaxLines, + softWrap, + ) { + state.applyCollapsedText( + textMeasurer = textMeasurer, + constraints = constraints, + overflowText = overflowText, + readMoreTextWithStyle = readMoreTextWithStyle, + style = style, + readMoreStyle = readMoreStyle, + text = text, + readMoreMaxLines = readMoreMaxLines, + softWrap = softWrap, + ) + } + } +} + +@Stable +private class ReadMoreState { + private var _collapsedText: AnnotatedString by mutableStateOf(AnnotatedString("")) + + var collapsedText: AnnotatedString + get() = _collapsedText + internal set(value) { + if (value != _collapsedText) { + _collapsedText = value + } + } + + val isCollapsible: Boolean + get() = collapsedText.isNotEmpty() + + @Suppress("LongParameterList") + fun applyCollapsedText( + textMeasurer: TextMeasurer, + constraints: Constraints, + overflowText: String, + readMoreTextWithStyle: AnnotatedString, + style: TextStyle, + readMoreStyle: SpanStyle, + text: AnnotatedString, + readMoreMaxLines: Int, + softWrap: Boolean, + ) { + val overflowTextWidth = if (overflowText.isNotEmpty()) { + textMeasurer.measure( + text = overflowText, + style = style, + ).size.width + } else { + 0 + } + val readMoreTextWidth = if (readMoreTextWithStyle.isNotEmpty()) { + textMeasurer.measure( + text = readMoreTextWithStyle, + style = style.merge(readMoreStyle), + ).size.width + } else { + 0 + } + val textLayout = textMeasurer.measure( + text = text, + style = style, + maxLines = readMoreMaxLines, + overflow = TextOverflow.Clip, + softWrap = softWrap, + constraints = constraints, + ) + + val clipTextCount = textLayout.getLineEnd(lineIndex = textLayout.lineCount - 1) + val isLineClipped = text.count() > clipTextCount + if (isLineClipped) { + val countUntilMaxLine = + textLayout.getLineEnd(readMoreMaxLines - 1, visibleEnd = true) + + val decorationWidth = overflowTextWidth + readMoreTextWidth + val replaceCount = text + .substringOf(textLayout, line = readMoreMaxLines) + .calculateReplaceCountToBeSingleLineWith( + maximumTextWidth = constraints.maxWidth - decorationWidth, + measureTextWidth = { subText -> + textMeasurer.measure( + text = subText, + style = style, + softWrap = softWrap, + ).size.width + }, + ) + collapsedText = text.subSequence(0, countUntilMaxLine - replaceCount) + } else { + collapsedText = AnnotatedString("") + } + } + + private fun AnnotatedString.substringOf(layout: TextLayoutResult, line: Int): AnnotatedString { + val lastLineStartIndex = layout.getLineStart(line - 1) + val lastLineEndIndex = layout.getLineEnd(line - 1, visibleEnd = true) + return subSequence(lastLineStartIndex, lastLineEndIndex) + } + + private inline fun AnnotatedString.calculateReplaceCountToBeSingleLineWith( + maximumTextWidth: Int, + measureTextWidth: (subText: AnnotatedString) -> Int, + ): Int { + var replacedTextWidth: Int + var replacedCount = -1 + do { + replacedCount++ + replacedTextWidth = measureTextWidth( + subSequence(0, this.length - replacedCount), + ) + } while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth) + + val lastVisibleChar: Char? = this.getOrNull(this.length - replacedCount - 1) + val firstOverflowChar: Char? = this.getOrNull(this.length - replacedCount) + if (lastVisibleChar?.isSurrogate() == true && firstOverflowChar?.isHighSurrogate() == false) { + val subText = subSequence(0, this.length - replacedCount) + if (subText.isNotEmpty()) { + return length - subText.indexOfLast { it.isHighSurrogate() } + } + } + return replacedCount + } +} + +@JvmInline +value class ToggleArea private constructor(internal val value: Int) { + + override fun toString(): String { + return when (this) { + All -> "All" + More -> "More" + else -> "Invalid" + } + } + + companion object { + /** + * All area of the text is clickable to toggle. + */ + @Stable + val All: ToggleArea = ToggleArea(1) + + /** + * 'More' and 'Less' area of the text is clickable to toggle. + */ + @Stable + val More: ToggleArea = ToggleArea(2) + } +} + +@JvmInline +value class ReadMoreTextOverflow private constructor(internal val value: Int) { + + override fun toString(): String { + return when (this) { + Clip -> "Clip" + Ellipsis -> "Ellipsis" + else -> "Invalid" + } + } + + companion object { + /** + * Clip the overflowing text to fix its container. + */ + @Stable + val Clip: ReadMoreTextOverflow = ReadMoreTextOverflow(1) + + /** + * Use an ellipsis to indicate that the text has overflowed. + */ + @Stable + val Ellipsis: ReadMoreTextOverflow = ReadMoreTextOverflow(2) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt index 03057d495d..f7020ce9e4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import com.tangem.core.ui.res.TangemTheme @@ -19,12 +20,13 @@ fun CurrencyIconTopBadge( alpha: Float, colorFilter: ColorFilter?, modifier: Modifier = Modifier, + background: Color = TangemTheme.colors.background.primary, ) { Box( modifier = modifier .size(TangemTheme.dimens.size18) .background( - color = TangemTheme.colors.background.primary, + color = background, shape = CircleShape, ), ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt index 7698a8067d..1d5172f597 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt @@ -95,6 +95,7 @@ data class TangemDimens internal constructor( val size142: Dp = 142.dp, val size158: Dp = 158.dp, val size164: Dp = 164.dp, + val size180: Dp = 180.dp, val size200: Dp = 200.dp, val size248: Dp = 248.dp, val size350: Dp = 350.dp, diff --git a/core/ui/src/main/res/drawable/ic_connect_new_24.xml b/core/ui/src/main/res/drawable/ic_connect_new_24.xml new file mode 100644 index 0000000000..523f1a7759 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_connect_new_24.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_network_new_24.xml b/core/ui/src/main/res/drawable/ic_network_new_24.xml new file mode 100644 index 0000000000..2e2f3e36ed --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_network_new_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_select_18_24.xml b/core/ui/src/main/res/drawable/ic_select_18_24.xml new file mode 100644 index 0000000000..5a131c0a6a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_select_18_24.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_wallet_new_24.xml b/core/ui/src/main/res/drawable/ic_wallet_new_24.xml new file mode 100644 index 0000000000..5add836f95 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_wallet_new_24.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_approvale2_20.xml b/core/ui/src/main/res/drawable/img_approvale2_20.xml new file mode 100644 index 0000000000..d5e74adaf9 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_approvale2_20.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_knight_shield_32.xml b/core/ui/src/main/res/drawable/img_knight_shield_32.xml new file mode 100644 index 0000000000..31dd7900c0 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_knight_shield_32.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt index 6e5a872f02..4676289125 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt @@ -24,7 +24,10 @@ class JobHolder { /** Cancel current [job] */ fun cancel() { job?.cancel() + job = null } + + fun isEmpty() = job == null } fun Job.saveIn(jobHolder: JobHolder): Job = jobHolder.update(job = this) diff --git a/data/blockaid/.gitignore b/data/blockaid/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/blockaid/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/blockaid/build.gradle.kts b/data/blockaid/build.gradle.kts new file mode 100644 index 0000000000..c1855c991d --- /dev/null +++ b/data/blockaid/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.android.library) + id("configuration") +} + +android { + namespace = "com.tangem.data.blockaid" +} + +dependencies { + /* Project - Domain */ + implementation(projects.data.common) + implementation(projects.domain.blockaid) + implementation(projects.domain.blockaid.models) + + /* Project - Data */ + implementation(projects.core.datasource) + + /* Project - Core */ + implementation(projects.core.utils) + + /* DI */ + implementation(deps.hilt.core) + kapt(deps.hilt.kapt) + + /* Tangem libraries */ + implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) + + /* Other */ + implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) + + /* Tests */ + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.turbine) +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt new file mode 100644 index 0000000000..5336bf9657 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidMapper.kt @@ -0,0 +1,128 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.transaction.* +import com.domain.blockaid.models.transaction.simultation.ApprovedAmount +import com.domain.blockaid.models.transaction.simultation.SimulationData +import com.domain.blockaid.models.transaction.simultation.TokenInfo +import com.domain.blockaid.models.transaction.simultation.AmountInfo +import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.RpcData +import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.response.* +import javax.inject.Inject + +private const val SUCCESS_STATUS = "Success" +private const val DOMAIN_CHECKED_STATUS = "hit" +private const val VALIDATION_SAFE_STATUS = "Benign" + +internal class BlockAidMapper @Inject constructor() { + + fun mapToDomain(from: DomainScanResponse): CheckDAppResult { + return when { + from.status != DOMAIN_CHECKED_STATUS -> CheckDAppResult.FAILED_TO_VERIFY + from.isMalicious == true -> CheckDAppResult.UNSAFE + else -> CheckDAppResult.SAFE + } + } + + fun mapToDomain(from: TransactionScanResponse): CheckTransactionResult { + return CheckTransactionResult( + validation = when { + from.validation.status != SUCCESS_STATUS -> ValidationResult.FAILED_TO_VALIDATE + from.validation.resultType == VALIDATION_SAFE_STATUS -> ValidationResult.SAFE + else -> ValidationResult.UNSAFE + }, + simulation = if (from.simulation.status != SUCCESS_STATUS) { + SimulationResult.FailedToSimulate + } else { + mapSimulationSuccessResult(from.simulation.accountSummary) + }, + ) + } + + fun mapToEvmRequest(from: TransactionData): EvmTransactionScanRequest { + return EvmTransactionScanRequest( + chain = from.chain, + accountAddress = from.accountAddress, + method = from.method, + data = RpcData( + method = from.method, + params = (from.params as TransactionParams.Evm).params, + ), + metadata = TransactionMetadata(from.domainUrl), + ) + } + + fun mapToSolanaRequest(from: TransactionData): SolanaTransactionScanRequest { + return SolanaTransactionScanRequest( + chain = from.chain, + accountAddress = from.accountAddress, + metadata = TransactionMetadata(from.domainUrl), + method = from.method, + transactions = (from.params as TransactionParams.Solana).transactions, + ) + } + + private fun mapSimulationSuccessResult(from: AccountSummaryResponse): SimulationResult { + return when { + from.assetsDiffs.isEmpty() && from.exposures.isNotEmpty() -> mapApproveTransaction(from.exposures) + from.assetsDiffs.isNotEmpty() && from.exposures.isEmpty() -> mapSendReceiveTransaction(from.assetsDiffs) + else -> SimulationResult.FailedToSimulate + } + } + + private fun mapApproveTransaction(exposures: List): SimulationResult { + val amounts = exposures.flatMap { exposure -> + val tokenInfo = TokenInfo( + chainId = exposure.asset.chainId, + logoUrl = exposure.asset.logoUrl, + symbol = exposure.asset.symbol, + ) + exposure.spenders.flatMap { (_, spender) -> + val isUnlimited = spender.isApprovedForAll == true + spender.exposure.mapNotNull { detail -> + ApprovedAmount( + approvedAmount = detail.value.toBigDecimalOrNull() ?: return@mapNotNull null, + isUnlimited = isUnlimited, + tokenInfo = tokenInfo, + ) + } + } + } + return if (amounts.isNotEmpty()) { + SimulationResult.Success(SimulationData.Approve(amounts)) + } else { + SimulationResult.FailedToSimulate + } + } + + private fun mapSendReceiveTransaction(assetDiffs: List): SimulationResult { + val sendInfo = arrayListOf() + val receiveInfo = arrayListOf() + + assetDiffs.forEach { diff -> + val token = TokenInfo( + chainId = diff.asset.chainId, + logoUrl = diff.asset.logoUrl, + symbol = diff.asset.symbol, + ) + diff.outTransfer.orEmpty().forEach { transfer -> + transfer.value.toBigDecimalOrNull()?.let { amount -> + sendInfo.add(AmountInfo(amount = amount, token = token)) + } + } + diff.inTransfer.orEmpty().forEach { transfer -> + transfer.value.toBigDecimalOrNull()?.let { amount -> + receiveInfo.add(AmountInfo(amount = amount, token = token)) + } + } + } + + return if (sendInfo.isNotEmpty() || receiveInfo.isNotEmpty()) { + SimulationResult.Success(SimulationData.SendAndReceive(send = sendInfo, receive = receiveInfo)) + } else { + SimulationResult.FailedToSimulate + } + } +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidRepository.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidRepository.kt new file mode 100644 index 0000000000..57e8db2559 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/BlockAidRepository.kt @@ -0,0 +1,13 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData + +interface BlockAidRepository { + + suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult + + suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt new file mode 100644 index 0000000000..837b699192 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt @@ -0,0 +1,40 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData +import com.domain.blockaid.models.transaction.TransactionParams +import com.tangem.datasource.api.common.blockaid.BlockAidApi +import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class DefaultBlockAidRepository @Inject constructor( + private val api: BlockAidApi, + private val dispatcherProvider: CoroutineDispatcherProvider, + private val mapper: BlockAidMapper, +) : BlockAidRepository { + + override suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult { + val response = withContext(dispatcherProvider.io) { + api.scanDomain(DomainScanRequest(data.url)) + } + return mapper.mapToDomain(response) + } + + override suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult { + val response = withContext(dispatcherProvider.io) { + when (data.params) { + is TransactionParams.Evm -> { + api.scanJsonRpc(mapper.mapToEvmRequest(data)) + } + is TransactionParams.Solana -> { + api.scanSolanaMessage(mapper.mapToSolanaRequest(data)) + } + } + } + return mapper.mapToDomain(response) + } +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidVerifier.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidVerifier.kt new file mode 100644 index 0000000000..95d5070701 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidVerifier.kt @@ -0,0 +1,31 @@ +package com.tangem.data.blockaid + +import arrow.core.Either +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData +import com.tangem.domain.blockaid.BlockAidVerifier +import javax.inject.Inject + +/** + * Verifies the safety of DApps and WalletConnect transactions + */ +class DefaultBlockAidVerifier @Inject constructor( + private val repository: BlockAidRepository, +) : BlockAidVerifier { + + /** + * Checks if a DApp is safe to use + */ + override suspend fun verifyDApp(data: DAppData): Either { + return Either.catch { repository.verifyDAppDomain(data) } + } + + /** + * Checks the safety of a WalletConnect transaction and provides a simulation result + */ + override suspend fun verifyTransaction(data: TransactionData): Either { + return Either.catch { repository.verifyTransaction(data) } + } +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataInternalModule.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataInternalModule.kt new file mode 100644 index 0000000000..894e9aca12 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataInternalModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.blockaid.di + +import com.tangem.data.blockaid.BlockAidRepository +import com.tangem.data.blockaid.DefaultBlockAidRepository +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 BlockAidDataInternalModule { + + @Binds + @Singleton + fun bindRepository(repository: DefaultBlockAidRepository): BlockAidRepository +} \ No newline at end of file diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataModule.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataModule.kt new file mode 100644 index 0000000000..1efc366513 --- /dev/null +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/di/BlockAidDataModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.blockaid.di + +import com.tangem.data.blockaid.DefaultBlockAidVerifier +import com.tangem.domain.blockaid.BlockAidVerifier +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface BlockAidDataModule { + + @Binds + @Singleton + fun bindVerifier(verifier: DefaultBlockAidVerifier): BlockAidVerifier +} \ No newline at end of file diff --git a/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt new file mode 100644 index 0000000000..ac9639b167 --- /dev/null +++ b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/BlockAidMapperTest.kt @@ -0,0 +1,155 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.transaction.SimulationResult +import com.domain.blockaid.models.transaction.ValidationResult +import com.domain.blockaid.models.transaction.simultation.SimulationData +import com.tangem.datasource.api.common.blockaid.models.response.* +import org.junit.Assert.* +import org.junit.Test +import java.math.BigDecimal + +class BlockAidMapperTest { + + private val mapper = BlockAidMapper() + + @Test + fun whenStatusHitAndIsMaliciousFalseThenMapToDomainReturnsSafe() { + val response = DomainScanResponse(status = "hit", isMalicious = false) + val result = mapper.mapToDomain(response) + assertEquals(CheckDAppResult.SAFE, result) + } + + @Test + fun whenStatusHitAndIsMaliciousTrueThenMapToDomainReturnsUnsafe() { + val response = DomainScanResponse(status = "hit", isMalicious = true) + val result = mapper.mapToDomain(response) + assertEquals(CheckDAppResult.UNSAFE, result) + } + + @Test + fun whenStatusNotHitThenMapToDomainReturnsFailedToVerify() { + val response = DomainScanResponse(status = "miss", isMalicious = false) + val result = mapper.mapToDomain(response) + assertEquals(CheckDAppResult.FAILED_TO_VERIFY, result) + } + + @Test + fun whenResponseBenignValidationThenReturnsSafeValidation() { + val spenderDetails = SpenderDetails( + isApprovedForAll = true, + exposure = listOf(ExposureDetail(value = "1000.0", rawValue = "0x123")), + ) + val exposure = Exposure( + asset = Asset(chainId = 1, logoUrl = "logo", symbol = "PEPE"), + spenders = mapOf("spender" to spenderDetails), + ) + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Benign"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse(assetsDiffs = emptyList(), exposures = listOf(exposure)), + ), + ) + + val result = mapper.mapToDomain(response) + assertEquals(ValidationResult.SAFE, result.validation) + + val simulation = result.simulation as? SimulationResult.Success + assertNotNull(simulation) + + val approve = simulation?.data as? SimulationData.Approve + assertNotNull(approve) + assertEquals(1, approve?.approvedAmounts?.size) + assertEquals(BigDecimal("1000.0"), approve?.approvedAmounts?.first()?.approvedAmount) + assertTrue(approve?.approvedAmounts?.first()?.isUnlimited == true) + } + + @Test + fun whenResponseBenignValidationAndSuccessSimulationThenReturnsSendReceiveResult() { + val assetDiff = AssetDiff( + assetType = "ERC20", + asset = Asset(chainId = 1, logoUrl = "logo", symbol = "ETH"), + inTransfer = listOf(Transfer(value = "2.0", rawValue = "0x1")), + outTransfer = listOf(Transfer(value = "1.5", rawValue = "0x2")), + ) + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Benign"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse(exposures = emptyList(), assetsDiffs = listOf(assetDiff)), + ), + ) + + val result = mapper.mapToDomain(response) + assertEquals(ValidationResult.SAFE, result.validation) + + val simulation = result.simulation as? SimulationResult.Success + assertNotNull(simulation) + + val data = simulation?.data as? SimulationData.SendAndReceive + assertNotNull(data) + assertEquals(BigDecimal("1.5"), data?.send?.first()?.amount) + assertEquals(BigDecimal("2.0"), data?.receive?.first()?.amount) + } + + @Test + fun whenResponseErrorValidationThenReturnsFailedToValidate() { + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Error", resultType = "Benign"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse(emptyList(), emptyList()), + ), + ) + + val result = mapper.mapToDomain(response) + assertEquals(ValidationResult.FAILED_TO_VALIDATE, result.validation) + assertTrue(result.simulation is SimulationResult.FailedToSimulate) + } + + @Test + fun whenResponseNotBenignThenReturnsValidationUnsafe() { + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Phishing"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse(emptyList(), emptyList()), + ), + ) + + val result = mapper.mapToDomain(response) + assertEquals(ValidationResult.UNSAFE, result.validation) + } + + @Test + fun whenResponseSimulationNotSuccessThenReturnsSimulationFailedToSimulate() { + val response = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Benign"), + simulation = SimulationResponse( + status = "Error", + accountSummary = AccountSummaryResponse(emptyList(), emptyList()), + ), + ) + + val result = mapper.mapToDomain(response) + assertTrue(result.simulation is SimulationResult.FailedToSimulate) + } + + @Test + fun whenResponseSimulationIsEmptyThenReturnsFailedToSimulate() { + val txResponse = TransactionScanResponse( + validation = ValidationResponse(status = "Success", resultType = "Benign"), + simulation = SimulationResponse( + status = "Success", + accountSummary = AccountSummaryResponse( + assetsDiffs = emptyList(), + exposures = emptyList(), + ), + ), + ) + + val result = mapper.mapToDomain(txResponse) + assertTrue(result.simulation is SimulationResult.FailedToSimulate) + } +} \ No newline at end of file diff --git a/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepositoryTest.kt b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepositoryTest.kt new file mode 100644 index 0000000000..bb919dbdb7 --- /dev/null +++ b/data/blockaid/src/test/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepositoryTest.kt @@ -0,0 +1,122 @@ +package com.tangem.data.blockaid + +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData +import com.domain.blockaid.models.transaction.TransactionParams +import com.tangem.datasource.api.common.blockaid.BlockAidApi +import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.request.SolanaTransactionScanRequest +import com.tangem.datasource.api.common.blockaid.models.response.DomainScanResponse +import com.tangem.datasource.api.common.blockaid.models.response.TransactionScanResponse +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultBlockAidRepositoryTest { + + @MockK + private lateinit var api: BlockAidApi + + @MockK + private lateinit var mapper: BlockAidMapper + + @MockK + private lateinit var dispatcherProvider: CoroutineDispatcherProvider + + private lateinit var repository: DefaultBlockAidRepository + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setup() { + MockKAnnotations.init(this) + Dispatchers.setMain(testDispatcher) + every { dispatcherProvider.io } returns testDispatcher + repository = DefaultBlockAidRepository(api, dispatcherProvider, mapper) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun whenVerifyAppDomainThenCallsApiAndMapsResult() = runTest { + val url = "https://example.com" + val domainData = DAppData(url) + val domainResponse = DomainScanResponse(status = "hit", isMalicious = false) + val expectedResult = CheckDAppResult.SAFE + + coEvery { api.scanDomain(DomainScanRequest(url)) } returns domainResponse + every { mapper.mapToDomain(domainResponse) } returns expectedResult + + val result = repository.verifyDAppDomain(domainData) + + assertEquals(expectedResult, result) + coVerify { api.scanDomain(DomainScanRequest(url)) } + verify { mapper.mapToDomain(domainResponse) } + } + + @Test + fun whenVerifyEvmTransactionThenCallsScanJsonRpcAndMaps() = runTest { + val data = TransactionData( + chain = "ethereum", + accountAddress = "0xabc", + domainUrl = "https://uniswap.org", + method = "eth_sendTransaction", + params = TransactionParams.Evm(params = "some-params"), + ) + + val request = mockk() + val response = mockk() + val expectedResult = mockk() + + every { mapper.mapToEvmRequest(data) } returns request + coEvery { api.scanJsonRpc(request) } returns response + every { mapper.mapToDomain(response) } returns expectedResult + + val result = repository.verifyTransaction(data) + + assertEquals(expectedResult, result) + coVerify { api.scanJsonRpc(request) } + verify { mapper.mapToEvmRequest(data) } + verify { mapper.mapToDomain(response) } + } + + @Test + fun whenVerifySolanaTransactionThenCallsScanSolanaMessageAndMapsResult() = runTest { + val data = TransactionData( + chain = "mainnet", + accountAddress = "/Rd2TLl...", + domainUrl = "https://example.com", + method = "signTransaction", + params = TransactionParams.Solana(transactions = listOf("TX_PAYLOAD_BASE64")), + ) + + val request = mockk() + val response = mockk() + val expectedResult = mockk() + + every { mapper.mapToSolanaRequest(data) } returns request + coEvery { api.scanSolanaMessage(request) } returns response + every { mapper.mapToDomain(response) } returns expectedResult + + val result = repository.verifyTransaction(data) + + assertEquals(expectedResult, result) + coVerify { api.scanSolanaMessage(request) } + verify { mapper.mapToSolanaRequest(data) } + verify { mapper.mapToDomain(response) } + } +} \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt index c1855597a2..7fc333d67d 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt @@ -1,6 +1,7 @@ package com.tangem.data.feedback.converters import com.tangem.domain.common.TapWorkarounds.isStart2Coin +import com.tangem.domain.common.TapWorkarounds.isVisa import com.tangem.domain.common.util.getBackupCardsCount import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.models.scan.CardDTO @@ -29,6 +30,7 @@ internal object CardInfoConverter : Converter { }, isImported = value.card.wallets.any(CardDTO.Wallet::isImported), isStart2Coin = value.card.isStart2Coin, + isVisa = value.card.isVisa, ) } } diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt index 274e82eae5..6dc441b61c 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt @@ -65,7 +65,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // check after producer.produce() verify { networksStatusesStore.get(params.userWalletId) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -95,7 +95,7 @@ internal class DefaultMultiNetworkStatusProducerTest { networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -112,7 +112,7 @@ internal class DefaultMultiNetworkStatusProducerTest { networksStatusesFlow.emit(updatedStatuses.map(NetworkStatus::toSimple).toSet()) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -143,7 +143,7 @@ internal class DefaultMultiNetworkStatusProducerTest { networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -155,7 +155,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // second emit networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -191,7 +191,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // check after producer.produce() verify { networksStatusesStore.get(params.userWalletId) } - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -202,7 +202,7 @@ internal class DefaultMultiNetworkStatusProducerTest { innerFlow.emit(value = true) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. @@ -222,7 +222,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // check after producer.produce() verify { networksStatusesStore.get(params.userWalletId) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) verify(inverse = true) { userWalletsStore.getSyncOrNull(params.userWalletId) } @@ -248,7 +248,7 @@ internal class DefaultMultiNetworkStatusProducerTest { // check after producer.produce() verify { networksStatusesStore.get(params.userWalletId) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) // Check after flow was observed by subscriber (getEmittedValues). // Otherwise, userWalletsStore.getSyncOrNull is not called. diff --git a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt index 98894ea109..09a2126524 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt @@ -53,7 +53,7 @@ internal class DefaultSingleNetworkStatusProducerTest { verify { multiNetworkStatusSupplier(multiParams) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(1) Truth.assertThat(values).isEqualTo(listOf(status)) @@ -74,7 +74,7 @@ internal class DefaultSingleNetworkStatusProducerTest { val status = MockNetworkStatusFactory.createMissedDerivation(params.network) expected.emit(value = setOf(status)) - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) Truth.assertThat(values1.size).isEqualTo(1) Truth.assertThat(values1).isEqualTo(listOf(status)) @@ -83,7 +83,7 @@ internal class DefaultSingleNetworkStatusProducerTest { val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null)) expected.emit(value = setOf(updatedStatus)) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) Truth.assertThat(values2.size).isEqualTo(2) Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus)) @@ -104,7 +104,7 @@ internal class DefaultSingleNetworkStatusProducerTest { val status = MockNetworkStatusFactory.createMissedDerivation(params.network) expected.emit(value = setOf(status)) - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) Truth.assertThat(values1.size).isEqualTo(1) Truth.assertThat(values1).isEqualTo(listOf(status)) @@ -112,7 +112,7 @@ internal class DefaultSingleNetworkStatusProducerTest { // second emit expected.emit(value = setOf(status)) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) Truth.assertThat(values2.size).isEqualTo(1) Truth.assertThat(values2).isEqualTo(listOf(status)) @@ -140,7 +140,7 @@ internal class DefaultSingleNetworkStatusProducerTest { verify { multiNetworkStatusSupplier(multiParams) } - val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values1 = getEmittedValues(flow = actual) Truth.assertThat(values1.size).isEqualTo(1) val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network) @@ -148,7 +148,7 @@ internal class DefaultSingleNetworkStatusProducerTest { innerFlow.emit(value = true) - val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values2 = getEmittedValues(flow = actual) Truth.assertThat(values2.size).isEqualTo(1) Truth.assertThat(values2).isEqualTo(listOf(status)) } @@ -166,7 +166,7 @@ internal class DefaultSingleNetworkStatusProducerTest { verify { multiNetworkStatusSupplier(multiParams) } - val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(0) } diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt index df73b079bb..522fcbeacc 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt @@ -30,7 +30,7 @@ internal class NetworksStatusesStoreGetMethodTest { fun `test get if runtime store is empty`() = runTest { val actual = store.get(userWalletId = userWalletId) - val values = backgroundScope.getEmittedValues(testScheduler, actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values).isEqualTo(emptyList>()) } @@ -41,7 +41,7 @@ internal class NetworksStatusesStoreGetMethodTest { val actual = store.get(userWalletId = userWalletId) - val values = backgroundScope.getEmittedValues(testScheduler, actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values).isEqualTo(emptyList>()) } @@ -54,7 +54,7 @@ internal class NetworksStatusesStoreGetMethodTest { val actual = store.get(userWalletId = userWalletId) - val values = backgroundScope.getEmittedValues(testScheduler, actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(1) Truth.assertThat(values).isEqualTo(listOf(emptySet())) @@ -70,7 +70,7 @@ internal class NetworksStatusesStoreGetMethodTest { val actual = store.get(userWalletId = userWalletId) - val values = backgroundScope.getEmittedValues(testScheduler, actual) + val values = getEmittedValues(flow = actual) Truth.assertThat(values.size).isEqualTo(1) Truth.assertThat(values).isEqualTo(listOf(setOf(status.toSimple()))) diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt index 08314ee68e..94d819b827 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt @@ -31,7 +31,7 @@ internal class NetworksStatusesStoreInitializationTest { DefaultNetworksStatusesStoreV2( runtimeStore = runtimeStore, - persistenceDataStore = persistenceStore, // local mock + persistenceDataStore = persistenceStore, dispatchers = TestingCoroutineDispatcherProvider(), ) diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index 197249ec85..7b806c688a 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -11,6 +11,7 @@ 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.NFTAsset import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.nft.models.NFTCollections import com.tangem.domain.nft.models.NFTSalePrice @@ -117,7 +118,7 @@ internal class DefaultNFTRepository @Inject constructor( assets.forEach { val assetId = NFTSdkAssetIdentifierConverter.convert(it.identifier) val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId) - if (price is NFTSalePrice.Error) { + if (price is NFTSalePrice.Empty || price is NFTSalePrice.Error) { refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier) } } @@ -155,6 +156,12 @@ internal class DefaultNFTRepository @Inject constructor( override suspend fun isNFTSupported(network: Network): Boolean = network.canHandleNFTs() + override suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? = + walletManagersFacade.getNFTExploreUrl( + network = network, + assetIdentifier = NFTSdkAssetIdentifierConverter.convertBack(assetIdentifier), + ) + private suspend fun refreshSalePrice( userWalletId: UserWalletId, network: Network, diff --git a/data/quotes/.gitignore b/data/quotes/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/quotes/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/quotes/build.gradle.kts b/data/quotes/build.gradle.kts new file mode 100644 index 0000000000..9005f39528 --- /dev/null +++ b/data/quotes/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.data.quotes" +} + +dependencies { + implementation(projects.core.datasource) + implementation(projects.core.utils) + + implementation(projects.data.common) + implementation(projects.data.tokens) + + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.quotes) + implementation(projects.domain.wallets.models) + + implementation(deps.androidx.datastore) + implementation(deps.timber) + + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt new file mode 100644 index 0000000000..a39da5f600 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/DefaultQuotesRepositoryV2.kt @@ -0,0 +1,25 @@ +package com.tangem.data.quotes + +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import javax.inject.Inject + +/** + * Default implementation of [QuotesRepositoryV2] + * + * @property quotesStore quotes store + * +[REDACTED_AUTHOR] + */ +internal class DefaultQuotesRepositoryV2 @Inject constructor( + private val quotesStore: QuotesStoreV2, +) : QuotesRepositoryV2 { + + override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set): Set? { + return quotesStore.getAllSyncOrNull()?.mapTo(hashSetOf()) { + it.takeIf { it.rawCurrencyId in currenciesIds } ?: Quote.Empty(it.rawCurrencyId) + } + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt new file mode 100644 index 0000000000..5a5eb2ecfd --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteFetcherModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.quotes.di + +import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher +import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteUpdater +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 QuoteFetcherModule { + + @Binds + @Singleton + fun bindMultiQuoteFetcher(impl: DefaultMultiQuoteFetcher): MultiQuoteFetcher + + @Binds + @Singleton + fun bindMultiQuoteUpdater(impl: DefaultMultiQuoteUpdater): MultiQuoteUpdater +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt new file mode 100644 index 0000000000..0fc3d0faed --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteProducerFactoryModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.quotes.di + +import com.tangem.data.quotes.single.DefaultSingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteProducer +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 QuoteProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleQuoteProducerFactory(impl: DefaultSingleQuoteProducer.Factory): SingleQuoteProducer.Factory +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt new file mode 100644 index 0000000000..af8188e5bc --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuoteSupplierModule.kt @@ -0,0 +1,42 @@ +package com.tangem.data.quotes.di + +import androidx.datastore.core.DataStore +import com.tangem.data.quotes.store.CurrencyIdWithQuote +import com.tangem.data.quotes.store.DefaultQuotesStoreV2 +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteSupplier +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object QuoteSupplierModule { + + @Provides + @Singleton + fun provideQuotesStoreV2( + persistenceQuotesStore: DataStore, + dispatchers: CoroutineDispatcherProvider, + ): QuotesStoreV2 { + return DefaultQuotesStoreV2( + runtimeStore = RuntimeSharedStore(), + persistenceDataStore = persistenceQuotesStore, + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideSingleQuoteSupplier(factory: SingleQuoteProducer.Factory): SingleQuoteSupplier { + return object : SingleQuoteSupplier( + factory = factory, + keyCreator = { "single_quote_${it.rawCurrencyId.value}" }, + ) {} + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt new file mode 100644 index 0000000000..e3c96931a8 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.quotes.di + +import com.tangem.data.quotes.DefaultQuotesRepositoryV2 +import com.tangem.domain.quotes.QuotesRepositoryV2 +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 QuotesDataModule { + + @Binds + @Singleton + fun bindQuotesRepositoryV2(impl: DefaultQuotesRepositoryV2): QuotesRepositoryV2 +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt new file mode 100644 index 0000000000..e9fa631d23 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt @@ -0,0 +1,85 @@ +package com.tangem.data.quotes.multi + +import arrow.core.Either +import com.tangem.data.common.api.safeApiCallWithTimeout +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Default implementation of [MultiQuoteFetcher] + * + * @property tangemTechApi tangemTech api + * @property appCurrencyResponseStore app currency response store + * @property quotesStore quotes store + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +@Singleton +internal class DefaultMultiQuoteFetcher @Inject constructor( + private val tangemTechApi: TangemTechApi, + private val appCurrencyResponseStore: AppCurrencyResponseStore, + private val quotesStore: QuotesStoreV2, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiQuoteFetcher { + + private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter() + + override suspend fun invoke(params: MultiQuoteFetcher.Params) = Either.catchOn(dispatchers.default) { + if (params.currenciesIds.isEmpty()) { + Timber.d("No currencies to fetch quotes for") + return@catchOn + } + + quotesStore.refresh(currenciesIds = params.currenciesIds) + + val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies( + currenciesIds = params.currenciesIds.mapTo( + destination = hashSetOf(), + transform = CryptoCurrency.RawID::value, + ), + ) + + val appCurrencyId = getAppCurrencyId(params = params) + val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",") + + val response = safeApiCallWithTimeout( + call = { tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind() }, + onError = { error -> throw error }, + ) + + val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies( + response = response, + filteredIds = replacementIdsResult.idsFiltered, + ) + + quotesStore.storeActual(values = updatedResponse.quotes) + } + .onLeft { + Timber.e(it) + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + private suspend fun getAppCurrencyId(params: MultiQuoteFetcher.Params): String { + val appCurrencyId = params.appCurrencyId + ?: appCurrencyResponseStore.getSyncOrNull()?.id + + if (appCurrencyId.isNullOrBlank()) { + val exception = IllegalStateException("Unable to get AppCurrency for updating quotes") + Timber.e(exception) + + throw exception + } + + return appCurrencyId + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt new file mode 100644 index 0000000000..06e492857e --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdater.kt @@ -0,0 +1,86 @@ +package com.tangem.data.quotes.multi + +import androidx.annotation.VisibleForTesting +import arrow.core.left +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.core.utils.EitherFlow +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.multi.MultiQuoteUpdater +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Default implementation of [MultiQuoteUpdater] which updates quotes when the app currency changes + * + * @property appCurrencyResponseStore app currency response store + * @property quotesStore quotes store + * @property multiQuoteFetcher multi quote fetcher + * @param dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +@Singleton +internal class DefaultMultiQuoteUpdater @Inject constructor( + private val appCurrencyResponseStore: AppCurrencyResponseStore, + private val quotesStore: QuotesStoreV2, + private val multiQuoteFetcher: MultiQuoteFetcher, + dispatchers: CoroutineDispatcherProvider, +) : MultiQuoteUpdater { + + private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default) + private val updaterHolder = JobHolder() + + override fun subscribe() { + Timber.d("Subscribe on quotes updates") + getMultiQuoteUpdates() + .launchIn(coroutineScope) + .saveIn(updaterHolder) + } + + override fun unsubscribe() { + Timber.e("Unsubscribe from quotes updates") + updaterHolder.cancel() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun getMultiQuoteUpdates(): EitherFlow { + return appCurrencyResponseStore.get() + .drop(count = 1) // skip initial value + .distinctUntilChanged() + .filterNotNull() + .mapLatest { appCurrency -> + val currenciesIds = quotesStore.getAllSyncOrNull().orEmpty() + .mapTo(destination = hashSetOf(), transform = Quote::rawCurrencyId) + + multiQuoteFetcher( + params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrency.id), + ) + .onLeft(Timber::e) + } + .retryWhen { cause, _ -> + Timber.e("Retry updating quotes: $cause") + + emit(cause.left()) + + delay(timeMillis = 2000) + true + } + } + + @VisibleForTesting(VisibleForTesting.NONE) + fun getMultiQuoteUpdatesFlow(): EitherFlow = getMultiQuoteUpdates() + + @VisibleForTesting(VisibleForTesting.NONE) + fun getUpdaterJobHolder(): JobHolder = updaterHolder +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt new file mode 100644 index 0000000000..d8450243b6 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt @@ -0,0 +1,40 @@ +package com.tangem.data.quotes.single + +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleQuoteProducer] + * + * @property params params + * @property quotesStore quotes store + */ +internal class DefaultSingleQuoteProducer @AssistedInject constructor( + @Assisted val params: SingleQuoteProducer.Params, + private val quotesStore: QuotesStoreV2, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleQuoteProducer { + + override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + + override fun produce(): Flow { + return quotesStore.get() + .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleQuoteProducer.Factory { + override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt new file mode 100644 index 0000000000..9fbbe97e50 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt @@ -0,0 +1,91 @@ +package com.tangem.data.quotes.store + +import androidx.datastore.core.DataStore +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.quote.converter.QuoteConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.addOrReplace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +internal typealias CurrencyIdWithQuote = Map + +/** + * Default implementation of [QuotesStoreV2] + * + * @property runtimeStore runtime store + * @property persistenceDataStore persistence store + * @param dispatchers dispatchers + */ +internal class DefaultQuotesStoreV2( + private val runtimeStore: RuntimeSharedStore>, + private val persistenceDataStore: DataStore, + dispatchers: CoroutineDispatcherProvider, +) : QuotesStoreV2 { + + private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) + + init { + scope.launch { + val cachedStatuses = persistenceDataStore.data.firstOrNull() + + if (cachedStatuses.isNullOrEmpty()) return@launch + + runtimeStore.store( + value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries), + ) + } + } + + override fun get(): Flow> = runtimeStore.get() + + override suspend fun getAllSyncOrNull(): Set? = runtimeStore.getSyncOrNull() + + override suspend fun refresh(currenciesIds: Set) { + updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE) + } + + override suspend fun storeActual(values: Map) { + coroutineScope { + launch { + val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries) + storeInRuntimeStore(values = quotes) + } + launch { storeInPersistenceStore(values = values) } + } + } + + override suspend fun storeError(currenciesIds: Set) { + updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.ONLY_CACHE) + } + + private suspend fun updateStatusSourceInRuntime(currenciesIds: Set, source: StatusSource) { + runtimeStore.update(default = emptySet()) { stored -> + val updatedQuotes = currenciesIds.mapTo(hashSetOf()) { id -> + val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: Quote.Empty(id) + + quote.copySealed(source = source) + } + + stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId } + } + } + + private suspend fun storeInRuntimeStore(values: Set) { + runtimeStore.update(default = emptySet()) { saved -> + saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId } + } + } + + private suspend fun storeInPersistenceStore(values: Map) { + persistenceDataStore.updateData { storedQuotes -> storedQuotes + values } + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt new file mode 100644 index 0000000000..ed31f3abf9 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt @@ -0,0 +1,25 @@ +package com.tangem.data.quotes.store + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import kotlinx.coroutines.flow.Flow + +/** Store of [Quote]'es set */ +internal interface QuotesStoreV2 { + + /** Get flow of quotes */ + fun get(): Flow> + + /** Get all quotes synchronously or null */ + suspend fun getAllSyncOrNull(): Set? + + /** Refresh status of [currenciesIds] */ + suspend fun refresh(currenciesIds: Set) + + /** Store actual map of currency ids and quotes [values] */ + suspend fun storeActual(values: Map) + + /** Store error for [currenciesIds] */ + suspend fun storeError(currenciesIds: Set) +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt new file mode 100644 index 0000000000..3e85896a19 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt @@ -0,0 +1,214 @@ +package com.tangem.data.quotes.multi + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiQuoteFetcherTest { + + private val tangemTechApi = mockk(relaxed = true) + private val appCurrencyResponseStore = mockk(relaxed = true) + private val quotesStore = mockk(relaxed = true) + + private val fetcher = DefaultMultiQuoteFetcher( + tangemTechApi = tangemTechApi, + appCurrencyResponseStore = appCurrencyResponseStore, + quotesStore = quotesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `fetch quotes successfully`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val coinIds = "BTC,ETH" + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + } returns ApiResponse.Success(successResponse) + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + appCurrencyResponseStore.getSyncOrNull() + + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + + quotesStore.storeActual(values = successResponse.quotes) + } + + coVerify(inverse = true) { + quotesStore.storeError(currenciesIds = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null) + + val actual = fetcher(params) + + coVerify(inverse = true) { + quotesStore.refresh(currenciesIds = any()) + appCurrencyResponseStore.getSyncOrNull() + tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesStore.storeActual(values = any()) + quotesStore.storeError(currenciesIds = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest { + val appCurrencyId = "usd" + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) + + val coinIds = "BTC,ETH" + coEvery { + tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds) + } returns ApiResponse.Success(successResponse) + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + + quotesStore.storeActual(values = successResponse.quotes) + } + + coVerify(inverse = true) { + appCurrencyResponseStore.getSyncOrNull() + quotesStore.storeError(currenciesIds = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest { + val appCurrencyId = "" + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + appCurrencyResponseStore.getSyncOrNull() + tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat() + .isEqualTo("Unable to get AppCurrency for updating quotes") + } + + @Test + fun `fetch quotes failure because api request failed`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val coinIds = "BTC,ETH" + + @Suppress("UNCHECKED_CAST") + val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse + coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + appCurrencyResponseStore.getSyncOrNull() + + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + } + + @Test + fun `fetch quotes failure because app currency not found`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + appCurrencyResponseStore.getSyncOrNull() + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + } + + private companion object { + + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + val usdAppCurrency = CurrenciesResponse.Currency( + id = "USD".lowercase(), + code = "USD", + name = "US Dollar", + unit = "$", + type = "fiat", + rateBTC = "", + ) + + val successResponse = QuotesResponse( + quotes = mapOf( + "BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), + "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN), + ), + ) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt new file mode 100644 index 0000000000..f14962f52b --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteUpdaterTest.kt @@ -0,0 +1,169 @@ +package com.tangem.data.quotes.multi + +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiQuoteUpdaterTest { + + private val appCurrencyResponseStore: AppCurrencyResponseStore = mockk() + private val quotesStore: QuotesStoreV2 = mockk() + private val multiQuoteFetcher: MultiQuoteFetcher = mockk() + + private val multiQuoteUpdater = DefaultMultiQuoteUpdater( + appCurrencyResponseStore = appCurrencyResponseStore, + quotesStore = quotesStore, + multiQuoteFetcher = multiQuoteFetcher, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test that initial app currency is skipped`() = runTest { + val appCurrencyFlow = flowOf(null, usdAppCurrency) + + every { appCurrencyResponseStore.get() } returns appCurrencyFlow + coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() + + val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteFetcher(params) } returns Unit.right() + + val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() + + coVerify(exactly = 1) { appCurrencyResponseStore.get() } + + val values = getEmittedValues(actual) + Truth.assertThat(values).isEqualTo(listOf(Unit.right())) + + coVerifyOrder { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(params) + } + } + + @Test + fun `test that flow is filtered the same status`() = runTest { + val appCurrencyFlow = flowOf(usdAppCurrency, usdAppCurrency) + + every { appCurrencyResponseStore.get() } returns appCurrencyFlow + coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() + + val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteFetcher(params) } returns Unit.right() + + val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() + + coVerify(exactly = 1) { appCurrencyResponseStore.get() } + + val values = getEmittedValues(actual) + Truth.assertThat(values).isEqualTo(listOf(Unit.right())) + + coVerifyOrder { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(params) + } + } + + @Test + fun `test that flow is filtered the null`() = runTest { + val appCurrencyFlow = flowOf(null, null) + + every { appCurrencyResponseStore.get() } returns appCurrencyFlow + + val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() + + coVerify(exactly = 1) { appCurrencyResponseStore.get() } + + val values = getEmittedValues(actual) + Truth.assertThat(values.size).isEqualTo(0) + + coVerify(inverse = true) { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(any()) + } + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + + val innerFlow = MutableStateFlow(value = false) + val appCurrencyFlow = flow { + if (innerFlow.value) { + emitAll(flowOf(null, usdAppCurrency)) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { appCurrencyResponseStore.get() } returns appCurrencyFlow + coEvery { quotesStore.getAllSyncOrNull() } returns emptySet() + + val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id) + coEvery { multiQuoteFetcher(params) } returns Unit.right() + + val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow() + + coVerify(exactly = 1) { appCurrencyResponseStore.get() } + + val values1 = getEmittedValues(actual) + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1.first().isLeft()).isTrue() + Truth.assertThat(values1.first().leftOrNull()).isInstanceOf(exception::class.java) + Truth.assertThat(values1.first().leftOrNull()).hasMessageThat().isEqualTo(exception.message) + + coVerify(inverse = true) { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(any()) + } + + innerFlow.emit(value = true) + + val values2 = getEmittedValues(flow = actual) + Truth.assertThat(values2).isEqualTo(listOf(Unit.right())) + + coVerifyOrder { + quotesStore.getAllSyncOrNull() + multiQuoteFetcher(params) + } + } + + @Test + fun `subscribe and unsubscribe successfully`() { + every { appCurrencyResponseStore.get() } returns emptyFlow() + + val updaterJobHolder = multiQuoteUpdater.getUpdaterJobHolder() + Truth.assertThat(updaterJobHolder.isEmpty()).isTrue() + + multiQuoteUpdater.subscribe() + + Truth.assertThat(updaterJobHolder.isEmpty()).isFalse() + + multiQuoteUpdater.unsubscribe() + + Truth.assertThat(updaterJobHolder.isEmpty()).isTrue() + } + + private companion object { + val usdAppCurrency = CurrenciesResponse.Currency( + id = "USD".lowercase(), + code = "USD", + name = "US Dollar", + unit = "$", + type = "fiat", + rateBTC = "", + ) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt new file mode 100644 index 0000000000..f228d65673 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt @@ -0,0 +1,177 @@ +package com.tangem.data.quotes.single + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.domain.models.StatusSource +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultSingleQuoteProducerTest { + + private val params = SingleQuoteProducer.Params( + rawCurrencyId = CryptoCurrency.RawID(value = "BTC"), + ) + + private val quotesStore = mockk() + + private val producer = DefaultSingleQuoteProducer( + params = params, + quotesStore = quotesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test that flow is mapped for network from params`() = runTest { + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + val storeQuote = flowOf( + setOf( + status, + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produce() + + verify { quotesStore.get() } + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(status)) + } + + @Test + fun `test that flow is updated if quote is updated`() = runTest { + val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + // first emit + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + storeQuote.emit(value = setOf(status)) + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(status)) + + // second emit + val updatedStatus = Quote.Value( + rawCurrencyId = params.rawCurrencyId, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + storeQuote.emit(value = setOf(updatedStatus)) + + val values2 = getEmittedValues(flow = actual) + + Truth.assertThat(values2.size).isEqualTo(2) + Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus)) + } + + @Test + fun `test that flow is filtered the same status`() = runTest { + val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + // first emit + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + storeQuote.emit(value = setOf(status)) + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(status)) + + // second emit + storeQuote.emit(value = setOf(status)) + + val values2 = getEmittedValues(flow = actual) + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(status)) + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + val status = Quote.Value( + rawCurrencyId = params.rawCurrencyId, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + + val innerFlow = MutableStateFlow(value = false) + val storeQuote = flow { + if (innerFlow.value) { + emit(setOf(status)) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus)) + + innerFlow.emit(value = true) + + val values2 = getEmittedValues(flow = actual) + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(status)) + } + + @Test + fun `test if flow doesn't contain network from params`() = runTest { + val storeFlow = flowOf( + setOf( + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + every { quotesStore.get() } returns storeFlow + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(0) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt new file mode 100644 index 0000000000..d5a517f5e2 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt @@ -0,0 +1,92 @@ +package com.tangem.data.quotes.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreGetMethodTest { + + private val runtimeStore = RuntimeSharedStore>() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test get if runtime store is empty`() = runTest { + val actual = store.get() + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values).isEqualTo(emptyList>()) + } + + @Test + fun `test get if runtime store contains empty set`() = runTest { + runtimeStore.store(value = emptySet()) + + val actual = store.get() + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + @Test + fun `test get if runtime store is not empty`() = runTest { + val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain())) + + val actual = store.get() + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain()))) + } + + @Test + fun `test getAllSyncOrNull if runtime store is empty`() = runTest { + val actual = store.getAllSyncOrNull() + + Truth.assertThat(actual).isEqualTo(null) + } + + @Test + fun `test getAllSyncOrNull if runtime store contains empty set`() = runTest { + runtimeStore.store(value = emptySet()) + + val actual = store.getAllSyncOrNull() + + Truth.assertThat(actual).isEqualTo(emptySet()) + } + + @Test + fun `test getAllSyncOrNull if runtime store is not empty`() = runTest { + val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain())) + + val actual = store.getAllSyncOrNull() + + val expected = setOf(btcQuote.toDomain(), ethQuote.toDomain()) + Truth.assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt new file mode 100644 index 0000000000..2452a66004 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt @@ -0,0 +1,82 @@ +package com.tangem.data.quotes.store + +import androidx.datastore.core.DataStore +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreInitializationTest { + + @Test + fun `test initialization if cache store is empty`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore: DataStore = mockk() + + every { persistenceStore.data } returns emptyFlow() + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) + } + + @Test + fun `test initialization if cache store contains empty map`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) + } + + @Test + fun `test initialization if cache store is not empty`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + persistenceStore.updateData { + it.toMutableMap().apply { + this += btcQuote + this += ethQuote + } + } + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val expected = setOf( + btcQuote.toDomain(source = StatusSource.CACHE), + ethQuote.toDomain(source = StatusSource.CACHE), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt new file mode 100644 index 0000000000..407b640fd9 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt @@ -0,0 +1,131 @@ +package com.tangem.data.quotes.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreUpdateMethodsTest { + + private val runtimeStore = RuntimeSharedStore>() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `refresh if runtime store is empty`() = runTest { + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + store.refresh(currenciesIds = currenciesIds) + + val runtimeExpected = currenciesIds.map(Quote::Empty).toSet() + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh if runtime store contains quote with this id`() = runTest { + val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + .toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL) + + runtimeStore.store(value = setOf(quote)) + + store.refresh(currenciesIds = setOf(quote.rawCurrencyId)) + + val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE)) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store actual if runtime and cache stores contain quotes with this id`() = runTest { + val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + + runtimeStore.store( + value = setOf( + prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE), + ), + ) + + persistenceStore.updateData { + it.toMutableMap().apply { + put("BTC", prevStatus) + } + } + + val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN) + + store.storeActual(values = mapOf("BTC" to newStatus)) + + val runtimeExpected = setOf( + newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL), + ) + val persistenceExpected = mapOf("BTC" to newStatus) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected) + } + + @Test + fun `store error if runtime store is empty`() = runTest { + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + store.storeError(currenciesIds = currenciesIds) + + val runtimeExpected = currenciesIds.map(Quote::Empty).toSet() + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store error if runtime store contains status with this network`() = runTest { + val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + + runtimeStore.store( + value = setOf( + status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE), + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + store.storeError( + currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ), + ) + + val runtimeExpected = setOf( + status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE), + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } +} \ No newline at end of file diff --git a/data/staking/build.gradle.kts b/data/staking/build.gradle.kts index 9af26ac76e..b4d8282fac 100644 --- a/data/staking/build.gradle.kts +++ b/data/staking/build.gradle.kts @@ -62,4 +62,11 @@ dependencies { } // endregion + + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(tangemDeps.card.core) + testImplementation(projects.common.test) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt new file mode 100644 index 0000000000..533610817a --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducer.kt @@ -0,0 +1,44 @@ +package com.tangem.data.staking.multi + +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEmpty + +/** + * Default implementation of [MultiYieldBalanceProducer] + * + * @property params params + * @property yieldsBalancesStore yields balances store + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiYieldBalanceProducer @AssistedInject constructor( + @Assisted val params: MultiYieldBalanceProducer.Params, + private val yieldsBalancesStore: YieldsBalancesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiYieldBalanceProducer { + + override val fallback: Set + get() = setOf() + + override fun produce(): Flow> { + return yieldsBalancesStore.get(userWalletId = params.userWalletId) + .distinctUntilChanged() + .onEmpty { emit(value = hashSetOf()) } + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : MultiYieldBalanceProducer.Factory { + override fun create(params: MultiYieldBalanceProducer.Params): DefaultMultiYieldBalanceProducer + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt new file mode 100644 index 0000000000..32beb14ec3 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/store/DefaultYieldsBalancesStore.kt @@ -0,0 +1,158 @@ +package com.tangem.data.staking.store + +import androidx.datastore.core.DataStore +import com.tangem.data.staking.store.YieldsBalancesStore.StakingID +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.token.converter.YieldBalanceConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.addOrReplace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +internal typealias WalletIdWithWrappers = Map> +internal typealias WalletIdWithBalances = Map> + +/** + * Default implementation of [YieldsBalancesStore] + * + * @property runtimeStore runtime store + * @property persistenceStore persistence store + * @param dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultYieldsBalancesStore( + private val runtimeStore: RuntimeSharedStore, + private val persistenceStore: DataStore, + dispatchers: CoroutineDispatcherProvider, +) : YieldsBalancesStore { + + private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) + + init { + scope.launch { + val cachedStatuses = persistenceStore.data.firstOrNull() ?: return@launch + + runtimeStore.store( + value = cachedStatuses.map { (stringWalletId, wrappers) -> + val key = UserWalletId(stringWalletId) + val value = YieldBalanceConverter(isCached = true).convertSet(input = wrappers) + + key to value + } + .toMap(), + ) + } + } + + override fun get(userWalletId: UserWalletId): Flow> { + return runtimeStore.get().mapNotNull { it[userWalletId] } + } + + override suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) { + updateBalanceInRuntime(userWalletId, stakingId) { + it.copySealed(source = StatusSource.CACHE) + } + } + + override suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) { + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + val storedBalances = this[userWalletId].orEmpty() + + val balances = stakingIds.mapTo(hashSetOf()) { stakingId -> + val balance = storedBalances.firstOrNull { + it.integrationId == stakingId.integrationId && + it.address == stakingId.address + } + ?: createDefaultBalance(id = stakingId) + + balance.copySealed(source = StatusSource.CACHE) + } + + val updatedBalances = storedBalances.addOrReplace(balances) { old, new -> + old.integrationId == new.integrationId && old.address == new.address + } + + put(key = userWalletId, value = updatedBalances) + } + } + } + + override suspend fun storeActual(userWalletId: UserWalletId, values: Set) { + coroutineScope { + launch { storeInRuntime(userWalletId = userWalletId, values = values) } + launch { storeInPersistence(userWalletId = userWalletId, values = values) } + } + } + + override suspend fun storeError(userWalletId: UserWalletId, stakingId: StakingID) { + updateBalanceInRuntime(userWalletId, stakingId) { + it.copySealed(source = StatusSource.ONLY_CACHE) + } + } + + private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set) { + val newBalances = YieldBalanceConverter(isCached = false).convertSet(input = values) + + runtimeStore.update(default = emptyMap()) { saved -> + saved.toMutableMap().apply { + this[userWalletId] = saved[userWalletId] + ?.addOrReplace(newBalances) { old, new -> + old.integrationId == new.integrationId && old.address == new.address + } + ?: newBalances + } + } + } + + private suspend fun storeInPersistence(userWalletId: UserWalletId, values: Set) { + persistenceStore.updateData { current -> + current.toMutableMap().apply { + this[userWalletId.stringValue] = current[userWalletId.stringValue] + ?.addOrReplace(items = values) { old, new -> + old.integrationId == new.integrationId && old.addresses.address == new.addresses.address + } + ?: values + } + } + } + + private suspend fun updateBalanceInRuntime( + userWalletId: UserWalletId, + stakingID: StakingID, + update: (YieldBalance) -> YieldBalance, + ) { + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + val balance = this[userWalletId].orEmpty() + .firstOrNull { + it.integrationId == stakingID.integrationId && + it.address == stakingID.address + } + ?: createDefaultBalance(id = stakingID) + + val updatedBalances = this[userWalletId].orEmpty() + .addOrReplace(item = update(balance)) { + it.integrationId == balance.integrationId && + it.address == balance.address + } + + put(key = userWalletId, value = updatedBalances) + } + } + } + + private fun createDefaultBalance(id: StakingID): YieldBalance { + return YieldBalance.Error(integrationId = id.integrationId, address = id.address) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt b/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt new file mode 100644 index 0000000000..2e5c062f3d --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/store/YieldsBalancesStore.kt @@ -0,0 +1,31 @@ +package com.tangem.data.staking.store + +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow + +/** + * Store of [YieldBalance]'s set + * +[REDACTED_AUTHOR] + */ +interface YieldsBalancesStore { + + /** Get flow of [YieldBalance]'s set by [userWalletId] */ + fun get(userWalletId: UserWalletId): Flow> + + /** Refresh balance of [stakingId] by [userWalletId] */ + suspend fun refresh(userWalletId: UserWalletId, stakingId: StakingID) + + /** Refresh balances of [stakingIds] by [userWalletId] */ + suspend fun refresh(userWalletId: UserWalletId, stakingIds: Set) + + /** Store actual [values] by [userWalletId] */ + suspend fun storeActual(userWalletId: UserWalletId, values: Set) + + /** Store error by [userWalletId] and [stakingId] */ + suspend fun storeError(userWalletId: UserWalletId, stakingId: StakingID) + + data class StakingID(val integrationId: String, val address: String) +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/YieldBalanceExt.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/YieldBalanceExt.kt new file mode 100644 index 0000000000..c04560fbeb --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/YieldBalanceExt.kt @@ -0,0 +1,10 @@ +package com.tangem.data.staking + +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.token.converter.YieldBalanceConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.staking.model.stakekit.YieldBalance + +internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource.CACHE): YieldBalance { + return YieldBalanceConverter(source = source).convert(this) +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt new file mode 100644 index 0000000000..a545c87d92 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceProducerTest.kt @@ -0,0 +1,190 @@ +package com.tangem.data.staking.multi + +import com.google.common.truth.Truth +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.toDomain +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.multi.MultiYieldBalanceProducer +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiYieldBalanceProducerTest { + + private val params = MultiYieldBalanceProducer.Params(userWalletId = UserWalletId("011")) + + private val yieldsBalancesStore = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val producer = DefaultMultiYieldBalanceProducer( + params = params, + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) + + @Test + fun `test that flow is mapped for user wallet id from params`() = runTest { + val balances = setOf( + MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(), + ) + + val networksStatusesFlow = flowOf(balances) + + every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + + val actual = producer.produce() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values.first()).isEqualTo(balances) + } + + @Test + fun `test that flow is updated if balances are updated`() = runTest { + val networksStatusesFlow = MutableSharedFlow>(replay = 2) + + every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + + val actual = producer.produce() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + // first emit + val balances = setOf( + MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(), + ) + + networksStatusesFlow.emit(balances) + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1.first()).isEqualTo(balances) + + // second emit + val updatedWrappers = setOf( + MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(), + ) + + networksStatusesFlow.emit(updatedWrappers) + + val values2 = getEmittedValues(flow = actual) + + val expected = listOf(balances, updatedWrappers) + Truth.assertThat(values2.size).isEqualTo(2) + Truth.assertThat(values2).isEqualTo(expected) + } + + @Test + fun `test that flow is filtered the same balance`() = runTest { + val networksStatusesFlow = MutableSharedFlow>(replay = 2) + + every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + + val actual = producer.produce() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + // first emit + val wrappers = setOf( + MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(), + ) + + networksStatusesFlow.emit(wrappers) + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1.first()).isEqualTo(wrappers) + + // second emit + networksStatusesFlow.emit(wrappers) + + val values2 = getEmittedValues(flow = actual) + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2.first()).isEqualTo(wrappers) + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + val balances = setOf( + MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain(), + MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId).toDomain(), + ) + + val innerFlow = MutableStateFlow(value = false) + val networksStatusesFlow = flow { + if (innerFlow.value) { + emit(balances) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { yieldsBalancesStore.get(params.userWalletId) } returns networksStatusesFlow + + val actual = producer.produceWithFallback() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + val values1 = getEmittedValues(flow = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(emptySet())) + + innerFlow.emit(value = true) + + val values2 = getEmittedValues(flow = actual) + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(balances)) + } + + @Test + fun `test that flow is empty`() = runTest { + every { yieldsBalancesStore.get(params.userWalletId) } returns emptyFlow() + + val actual = producer.produce() + + // check after producer.produce() + verify { yieldsBalancesStore.get(params.userWalletId) } + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + private companion object { + + val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + val solanaId = YieldsBalancesStore.StakingID( + integrationId = "solana-sol-native-multivalidator-staking", + address = "0x1", + ) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt new file mode 100644 index 0000000000..7900b655e1 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreGetMethodTest.kt @@ -0,0 +1,83 @@ +package com.tangem.data.staking.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.staking.toDomain +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class YieldsBalancesStoreGetMethodTest { + + private val runtimeStore = RuntimeSharedStore() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test get if runtime store is empty`() = runTest { + val actual = store.get(userWalletId = userWalletId) + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values).isEqualTo(emptyList>()) + } + + @Test + fun `test get if runtime store contains empty map`() = runTest { + runtimeStore.store(value = emptyMap()) + + val actual = store.get(userWalletId = userWalletId) + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values).isEqualTo(emptyList>()) + } + + @Test + fun `test get if runtime store contains portfolio with empty balances`() = runTest { + runtimeStore.store( + value = mapOf(userWalletId to emptySet()), + ) + + val actual = store.get(userWalletId = userWalletId) + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + @Test + fun `test get if runtime store is not empty`() = runTest { + val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance() + + runtimeStore.store( + value = mapOf(userWalletId to setOf(wrapper.toDomain())), + ) + + val actual = store.get(userWalletId = userWalletId) + + val values = getEmittedValues(flow = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(setOf(wrapper.toDomain()))) + } + + private companion object { + + val userWalletId = UserWalletId(stringValue = "011") + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreInitializationTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreInitializationTest.kt new file mode 100644 index 0000000000..13bb51d6e3 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreInitializationTest.kt @@ -0,0 +1,80 @@ +package com.tangem.data.staking.store + +import androidx.datastore.core.DataStore +import com.google.common.truth.Truth +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.data.staking.toDomain +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class YieldsBalancesStoreInitializationTest { + + @Test + fun `test initialization if cache store is empty`() = runTest { + val runtimeStore = RuntimeSharedStore() + val persistenceStore: DataStore = mockk() + + every { persistenceStore.data } returns emptyFlow() + + DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) + } + + @Test + fun `test initialization if cache store contains empty map`() = runTest { + val runtimeStore = RuntimeSharedStore() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `test initialization if cache store is not empty`() = runTest { + val runtimeStore = RuntimeSharedStore() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance() + + persistenceStore.updateData { + it.toMutableMap().apply { + put(userWalletId.stringValue, setOf(wrapper)) + } + } + + DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val expected = mapOf(userWalletId to setOf(wrapper.toDomain())) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected) + } + + private companion object { + val userWalletId = UserWalletId(stringValue = "011") + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreUpdateMethodsTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreUpdateMethodsTest.kt new file mode 100644 index 0000000000..f64a6da8e8 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/store/YieldsBalancesStoreUpdateMethodsTest.kt @@ -0,0 +1,188 @@ +package com.tangem.data.staking.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.data.staking.toDomain +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class YieldsBalancesStoreUpdateMethodsTest { + + private val runtimeStore = RuntimeSharedStore() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultYieldsBalancesStore( + runtimeStore = runtimeStore, + persistenceStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `refresh the single id if runtime store is empty`() = runTest { + store.refresh(userWalletId = userWalletId, stakingId = stakingId) + + val runtimeExpected = mapOf( + userWalletId to setOf( + YieldBalance.Error( + integrationId = stakingId.integrationId, + address = stakingId.address, + ), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the single id if runtime store contains balance with this id`() = runTest { + val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance().toDomain(source = StatusSource.ACTUAL) + + runtimeStore.store( + value = mapOf(userWalletId to setOf(balance)), + ) + + store.refresh(userWalletId = userWalletId, stakingId = stakingId) + + val runtimeExpected = mapOf( + userWalletId to setOf( + balance.copySealed(source = StatusSource.CACHE), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the multi ids if runtime store is empty`() = runTest { + store.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + + val runtimeExpected = mapOf( + userWalletId to stakingIds.mapTo(hashSetOf()) { + YieldBalance.Error(integrationId = it.integrationId, address = it.address) + }, + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the multi ids if runtime store contains balance with this id`() = runTest { + val firstBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.first()) + .toDomain(source = StatusSource.ACTUAL) + + val secondBalance = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId = stakingIds.last()) + .toDomain(source = StatusSource.ACTUAL) + + runtimeStore.store( + value = mapOf(userWalletId to setOf(firstBalance, secondBalance)), + ) + + store.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + + val runtimeExpected = mapOf( + userWalletId to setOf( + firstBalance.copySealed(source = StatusSource.CACHE), + secondBalance.copySealed(source = StatusSource.CACHE), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store actual if runtime and cache stores contain balance with this id`() = runTest { + val prevWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance() + + runtimeStore.store( + value = mapOf(userWalletId to setOf(prevWrapper.toDomain(source = StatusSource.CACHE))), + ) + + persistenceStore.updateData { + it.toMutableMap().apply { + put(userWalletId.stringValue, setOf(prevWrapper)) + } + } + + val newWrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance() + + store.storeActual(userWalletId, setOf(newWrapper)) + + val runtimeExpected = mapOf( + userWalletId to setOf(newWrapper.toDomain(source = StatusSource.ACTUAL)), + ) + + val persistenceExpected = mapOf( + userWalletId.stringValue to setOf(newWrapper), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected) + } + + @Test + fun `store error if runtime store is empty`() = runTest { + store.storeError(userWalletId = userWalletId, stakingId = stakingId) + + val runtimeExpected = mapOf( + userWalletId to setOf( + YieldBalance.Error( + integrationId = stakingId.integrationId, + address = stakingId.address, + ), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store error if runtime store contains balance with this id`() = runTest { + val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId) + + runtimeStore.store( + value = mapOf( + userWalletId to setOf(wrapper.toDomain(source = StatusSource.CACHE)), + ), + ) + + store.storeError(userWalletId = userWalletId, stakingId = stakingId) + + val runtimeExpected = mapOf( + userWalletId to setOf(wrapper.toDomain(source = StatusSource.ONLY_CACHE)), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + private companion object { + + val userWalletId = UserWalletId(stringValue = "011") + + val stakingId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + + val stakingIds = setOf( + stakingId, + YieldsBalancesStore.StakingID( + integrationId = "solana-sol-native-multivalidator-staking", + address = "0x1", + ), + ) + } +} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt index 66f0ea8b64..bf7779378a 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt @@ -8,7 +8,7 @@ import com.tangem.datasource.api.tangemTech.models.QuotesResponse /** * Adapter to replace unsupported currencies for quotes request if it necessary */ -internal class QuotesUnsupportedCurrenciesIdAdapter { +class QuotesUnsupportedCurrenciesIdAdapter { /** * Replaces unsupported currencies id to it replacements for request diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index 07f16b38b8..da6951cafd 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -14,14 +14,14 @@ import com.tangem.blockchain.blockchains.ton.TonTransactionExtras import com.tangem.blockchain.blockchains.tron.TronTransactionExtras import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.common.extensions.hexToBytes import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository -import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -29,7 +29,6 @@ import kotlinx.coroutines.withContext import timber.log.Timber import java.math.BigDecimal import java.math.BigInteger -import com.tangem.blockchain.blockchains.tron.TransactionType as SdkTransactionType internal class DefaultTransactionRepository( private val walletManagersFacade: WalletManagersFacade, @@ -45,7 +44,6 @@ internal class DefaultTransactionRepository( userWalletId: UserWalletId, network: Network, txExtras: TransactionExtras?, - hash: String?, ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { val blockchain = Blockchain.fromId(network.id.value) val walletManager = walletManagersFacade.getOrCreateWalletManager( @@ -54,14 +52,50 @@ internal class DefaultTransactionRepository( derivationPath = network.derivationPath.value, ) ?: error("Wallet manager not found") - return@withContext walletManager.createTransactionDataInternal( + return@withContext walletManager.createTransaction( amount = amount, fee = fee, - memo = memo, destination = destination, + ).copy( + extras = txExtras ?: getMemoExtras(networkId = network.id.value, memo), + ) + } + + override suspend fun createTransferTransaction( + amount: Amount, + fee: Fee, + memo: String?, + destination: String, + userWalletId: UserWalletId, + network: Network, + ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { + val blockchain = Blockchain.fromId(network.id.value) + + val callData = SmartContractCallDataProviderFactory.getTokenTransferCallData( + destinationAddress = destination, + amount = amount, + blockchain = blockchain, + ) + + val extras = if (amount.type is AmountType.Token && callData != null) { + createTransactionDataExtras( + callData = callData, + network = network, + nonce = null, + gasLimit = null, + ) + } else { + null + } + + return@withContext createTransaction( + amount = amount, + fee = fee, + memo = null, + destination = destination, + userWalletId = userWalletId, network = network, - txExtras = txExtras, - hash = hash, + txExtras = getMemoExtras(networkId = network.id.value, memo = memo) ?: extras, ) } @@ -73,25 +107,16 @@ internal class DefaultTransactionRepository( spenderAddress: String, userWalletId: UserWalletId, network: Network, - hash: String?, ): TransactionData.Uncompiled = withContext(coroutineDispatcherProvider.io) { val blockchain = Blockchain.fromId(network.id.value) - val walletManager = walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = blockchain, - derivationPath = network.derivationPath.value, - ) ?: error("Wallet manager not found") - val approver = walletManager as? Approver ?: error("Cannot cast to Approver") - - val approvalData = approver.getApproveData( - spenderAddress = spenderAddress, - value = approvalAmount, - ) val extras = createTransactionDataExtras( - data = approvalData, + callData = SmartContractCallDataProviderFactory.getApprovalCallData( + spenderAddress = spenderAddress, + amount = approvalAmount, + blockchain = blockchain, + ), network = network, - transactionType = TransactionType.APPROVE, nonce = null, gasLimit = null, ) @@ -104,7 +129,6 @@ internal class DefaultTransactionRepository( userWalletId = userWalletId, network = network, txExtras = extras, - hash = hash, ) } @@ -115,9 +139,6 @@ internal class DefaultTransactionRepository( destination: String, userWalletId: UserWalletId, network: Network, - isSwap: Boolean, - txExtras: TransactionExtras?, - hash: String?, ): Result = withContext(coroutineDispatcherProvider.io) { val blockchain = Blockchain.fromId(network.id.value) val walletManager = walletManagersStore.getSyncOrNull( @@ -129,14 +150,12 @@ internal class DefaultTransactionRepository( val validator = walletManager as? TransactionValidator if (validator != null) { - val transactionData = walletManager.createTransactionDataInternal( + val transactionData = walletManager.createTransaction( amount = amount, fee = fee ?: Fee.Common(amount = amount), - memo = memo, destination = destination, - network = network, - txExtras = txExtras, - hash = hash, + ).copy( + extras = getMemoExtras(networkId = network.id.value, memo = memo), ) validator.validate(transactionData = transactionData) @@ -178,9 +197,8 @@ internal class DefaultTransactionRepository( } override fun createTransactionDataExtras( - data: String, + callData: SmartContractCallData, network: Network, - transactionType: TransactionType, nonce: BigInteger?, gasLimit: BigInteger?, ): TransactionExtras { @@ -189,15 +207,14 @@ internal class DefaultTransactionRepository( return when { blockchain.isEvm() -> { EthereumTransactionExtras( - data = data.hexToBytes(), + callData = callData, gasLimit = gasLimit, nonce = nonce, ) } blockchain == Blockchain.Tron -> { TronTransactionExtras( - data = data.hexToBytes(), - txType = convertToSdkTransactionType(transactionType), + callData = callData, ) } else -> error("Data extras not supported for $blockchain") @@ -226,33 +243,6 @@ internal class DefaultTransactionRepository( ) } - private fun convertToSdkTransactionType(transactionType: TransactionType): SdkTransactionType { - return when (transactionType) { - TransactionType.APPROVE -> SdkTransactionType.APPROVE - } - } - - @Suppress("LongParameterList") - private fun WalletManager.createTransactionDataInternal( - amount: Amount, - fee: Fee, - memo: String?, - destination: String, - network: Network, - txExtras: TransactionExtras?, - hash: String?, - ): TransactionData.Uncompiled { - if (txExtras != null && memo != null) { - // throw error for now to avoid programmers errors when use extras - error("Both txExtras and memo provided, use only one of them") - } - val extras = txExtras ?: getMemoExtras(network.id.value, memo) - return createTransaction(amount, fee, destination).copy( - hash = hash, - extras = extras, - ) - } - @Suppress("CyclomaticComplexMethod") private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? { val blockchain = Blockchain.fromId(networkId) @@ -280,4 +270,27 @@ internal class DefaultTransactionRepository( else -> null } } + + override suspend fun prepareForSend( + transactionData: TransactionData, + signer: TransactionSigner, + userWalletId: UserWalletId, + network: Network, + ): Result = withContext(coroutineDispatcherProvider.io) { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + val preparer = walletManager as? TransactionPreparer ?: kotlin.run { + Timber.e("${walletManager?.wallet?.blockchain} does not support TransactionBuilder") + error("Wallet manager does not support TransactionPreparer") + } + + when (val prepareForSend = preparer.prepareForSend(transactionData, signer)) { + is com.tangem.blockchain.extensions.Result.Failure -> Result.failure(prepareForSend.error) + is com.tangem.blockchain.extensions.Result.Success -> Result.success(prepareForSend.data) + } + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt index d82a09116a..b67a8484e1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt @@ -1,15 +1,60 @@ package com.tangem.data.visa.converter import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse +import com.tangem.domain.visa.model.VisaActivationOrderInfo import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.utils.converter.Converter +import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject +import javax.inject.Singleton +@Singleton class VisaActivationStatusConverter @Inject constructor() : Converter { + private val lastUpdatedAt = MutableStateFlow(null) + override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState { + // handle pin code error + // either we entered pin code before with an error (WasError) or after receiving an error (InProgress) + if ( + value.status == Status.AwaitingPin.stringValue && + value.stepChangeCode != null && + value.stepChangeCode == PIN_CODE_VALIDATION_ERROR + ) { + return if (lastUpdatedAt.value != value.updatedAt) { + lastUpdatedAt.value == value.updatedAt + + VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = value.activationOrder!!.convert(), + status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError, + ) + } else { + VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = value.activationOrder!!.convert(), + status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress, + ) + } + } + // TODO Will be implemented in the future return VisaActivationRemoteState.Activated } + + private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo { + return VisaActivationOrderInfo( + orderId = id, + customerId = customerId, + customerWalletAddress = customerWalletAddress, + ) + } + + private enum class Status(val stringValue: String) { + AwaitingPin("AWAITING_PIN"), + // TODO complete statuses list when backend is ready + } + + private companion object { + const val PIN_CODE_VALIDATION_ERROR = 1000 + } } \ No newline at end of file diff --git a/data/wallet-connect/build.gradle.kts b/data/wallet-connect/build.gradle.kts index 159348516a..cb90e05209 100644 --- a/data/wallet-connect/build.gradle.kts +++ b/data/wallet-connect/build.gradle.kts @@ -14,6 +14,8 @@ dependencies { /* Project - Domain */ implementation(projects.domain.walletConnect) implementation(projects.domain.walletConnect.models) + implementation(projects.domain.transaction) + implementation(projects.domain.transaction.models) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens) @@ -35,6 +37,7 @@ dependencies { /* Tangem libraries */ implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) /* Reown - WalletConnect */ implementation(deps.reownCore) { @@ -47,4 +50,11 @@ dependencies { /* Other */ implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) + + /* Tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.turbine) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index edab0d12af..1a5345959e 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt @@ -5,27 +5,25 @@ import com.squareup.moshi.Moshi import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.walletconnect.DefaultWalletConnectRepository import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase -import com.tangem.data.walletconnect.model.NamespaceKey import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase +import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.request.DefaultWcRequestService -import com.tangem.data.walletconnect.request.WcMethodHandler +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter import com.tangem.data.walletconnect.respond.DefaultWcRespondService +import com.tangem.data.walletconnect.respond.WcRespondService import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.datasource.di.SdkMoshi import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.datasource.local.walletconnect.WalletConnectStore import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.walletconnect.model.WcMethod import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletconnect.repository.WalletConnectRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager -import com.tangem.domain.walletconnect.request.WcRequestService -import com.tangem.domain.walletconnect.respond.WcRespondService import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase import com.tangem.domain.walletmanager.WalletManagersFacade @@ -58,12 +56,12 @@ internal object WalletConnectDataModule { application: Application, sessionsManager: DefaultWcSessionsManager, networkService: DefaultWcRequestService, - wcPairFlow: DefaultWcPairUseCase, + pairSdkDelegate: WcPairSdkDelegate, ): WcInitializeUseCase = DefaultWcInitializeUseCase( application = application, sessionsManager = sessionsManager, networkService = networkService, - wcPairFlow = wcPairFlow, + pairSdkDelegate = pairSdkDelegate, ) @Provides @@ -72,16 +70,22 @@ internal object WalletConnectDataModule { sessionsManager: WcSessionsManager, associateNetworksDelegate: AssociateNetworksDelegate, caipNamespaceDelegate: CaipNamespaceDelegate, + sdkDelegate: WcPairSdkDelegate, ): DefaultWcPairUseCase = DefaultWcPairUseCase( sessionsManager = sessionsManager, associateNetworksDelegate = associateNetworksDelegate, caipNamespaceDelegate = caipNamespaceDelegate, + sdkDelegate = sdkDelegate, ) @Provides @Singleton fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default + @Provides + @Singleton + fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate() + @Provides @Singleton fun defaultWcSessionsManager( @@ -107,38 +111,39 @@ internal object WalletConnectDataModule { @Provides @Singleton fun defaultWcRequestService( - sessionsManager: WcSessionsManager, - respondService: WcRespondService, dispatchers: CoroutineDispatcherProvider, diHelperBox: DiHelperBox, ): DefaultWcRequestService { val scope = CoroutineScope(SupervisorJob() + dispatchers.io) return DefaultWcRequestService( - sessionsManager = sessionsManager, - respondService = respondService, - requestAdapters = diHelperBox.handlers, + requestConverters = diHelperBox.handlers, scope = scope, ) } - @Provides - @Singleton - fun wcRequestService(default: DefaultWcRequestService): WcRequestService = default - @Provides @Singleton fun wcRespondService(): WcRespondService = DefaultWcRespondService() @Provides @Singleton - fun wcEthNetwork(@SdkMoshi moshi: Moshi, respondService: WcRespondService): WcEthNetwork = WcEthNetwork( + fun wcEthNetwork( + @SdkMoshi moshi: Moshi, + excludedBlockchains: ExcludedBlockchains, + sessionsManager: WcSessionsManager, + factories: WcEthNetwork.Factories, + ): WcEthNetwork = WcEthNetwork( moshi = moshi, - respondService = respondService, + excludedBlockchains = excludedBlockchains, + sessionsManager = sessionsManager, + factories = factories, ) @Provides @Singleton - fun wcSolanaNetwork(): WcSolanaNetwork = WcSolanaNetwork() + fun wcSolanaNetwork(excludedBlockchains: ExcludedBlockchains): WcSolanaNetwork = WcSolanaNetwork( + excludedBlockchains = excludedBlockchains, + ) @Provides @Singleton @@ -156,12 +161,10 @@ internal object WalletConnectDataModule { diHelperBox: DiHelperBox, getWallets: GetWalletsUseCase, currenciesRepository: CurrenciesRepository, - excludedBlockchains: ExcludedBlockchains, ): AssociateNetworksDelegate = AssociateNetworksDelegate( namespaceConverters = diHelperBox.converters, getWallets = getWallets, currenciesRepository = currenciesRepository, - excludedBlockchains = excludedBlockchains, ) @Provides @@ -170,14 +173,14 @@ internal object WalletConnectDataModule { handlers = setOf( ethNetwork, ), - converters = buildMap { - ethNetwork.namespaceKey to ethNetwork - solanaNetwork.namespaceKey to solanaNetwork - }, + converters = setOf( + ethNetwork, + solanaNetwork, + ), ) internal class DiHelperBox( - val converters: Map, - val handlers: Set>, + val converters: Set, + val handlers: Set, ) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt index 6330859a84..91db31c6c8 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt @@ -6,7 +6,7 @@ import com.reown.android.CoreClient import com.reown.android.relay.ConnectionType import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit -import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase +import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.request.DefaultWcRequestService import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager import com.tangem.data.walletconnect.utils.WcSdkObserver @@ -17,13 +17,13 @@ internal class DefaultWcInitializeUseCase( private val application: Application, private val sessionsManager: DefaultWcSessionsManager, private val networkService: DefaultWcRequestService, - private val wcPairFlow: DefaultWcPairUseCase, + private val pairSdkDelegate: WcPairSdkDelegate, ) : WcInitializeUseCase { private val wcSdkObservers = mutableSetOf( sessionsManager, networkService, - wcPairFlow, + pairSdkDelegate, ) override fun init(projectId: String) { diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthMessageSignUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthMessageSignUseCase.kt new file mode 100644 index 0000000000..8421ccb613 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthMessageSignUseCase.kt @@ -0,0 +1,102 @@ +package com.tangem.data.walletconnect.network.ethereum + +import arrow.core.left +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils.toKeccak +import com.tangem.blockchain.common.HEX_PREFIX +import com.tangem.blockchain.extensions.isAscii +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toDecompressedPublicKey +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.OnSign +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.domain.transaction.usecase.SignUseCase +import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMessageSignUseCase +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +internal class DefaultWcEthMessageSignUseCase @AssistedInject constructor( + override val respondService: WcRespondService, + @Assisted override val context: WcMethodUseCaseContext, + @Assisted private val method: WcEthMethod.MessageSign, + private val walletManagersFacade: WalletManagersFacade, + private val signUseCase: SignUseCase, +) : BaseWcSignUseCase(), + WcEthMessageSignUseCase { + + override val onSign: OnSign = collector@{ state -> + val hashToSign = LegacySdkHelper.createMessageData(state.signModel.rawMsg) + val userWallet = session.wallet + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) + ?: return@collector + + val signedHash = signUseCase(hashToSign, userWallet, network) + .onLeft { emit(state.toResult(it.left())) } + .getOrNull() ?: return@collector + + val respond = EthereumUtils.prepareSignedMessageData( + signedHash = signedHash, + hashToSign = hashToSign, + publicKey = walletManager.wallet.publicKey.blockchainKey.toDecompressedPublicKey(), + ) + + val wcRespondResult = respondService.respond(rawSdkRequest, respond) + emit(state.toResult(wcRespondResult)) + } + + override fun invoke(): Flow> = flow { + val model = WcEthMessageSignUseCase.SignModel( + rawMsg = method.message, + account = method.account, + humanMsg = LegacySdkHelper.hexToAscii(method.message).orEmpty(), + ) + emitAll(delegate(model)) + } + + @AssistedFactory + interface Factory { + fun create(context: WcMethodUseCaseContext, method: WcEthMethod.MessageSign): DefaultWcEthMessageSignUseCase + } +} + +object LegacySdkHelper { + private const val ETH_MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n" + + fun createMessageData(message: String): ByteArray { + val messageData = try { + message.removePrefix(HEX_PREFIX).hexToBytes() + } catch (exception: Exception) { + message.asciiToHex()?.hexToBytes() ?: byteArrayOf() + } + + val prefixData = (ETH_MESSAGE_PREFIX + messageData.size.toString()).toByteArray() + return (prefixData + messageData).toKeccak() + } + + fun hexToAscii(hex: String): String? { + return try { + hex.removePrefix(HEX_PREFIX).hexToBytes().map { + val char = it.toInt().toChar() + if (char.isAscii()) char else return null + }.joinToString("") + } catch (exception: Exception) { + return null + } + } + + private fun String.asciiToHex(): String? { + return map { + if (!it.isAscii()) return null + Integer.toHexString(it.code) + }.joinToString("") + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSendTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSendTransactionUseCase.kt new file mode 100644 index 0000000000..76e1b410de --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSendTransactionUseCase.kt @@ -0,0 +1,57 @@ +package com.tangem.data.walletconnect.network.ethereum + +import arrow.core.left +import com.tangem.blockchain.common.HEX_PREFIX +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.OnSign +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthSendTransactionUseCase +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class DefaultWcEthSendTransactionUseCase @AssistedInject constructor( + @Assisted override val context: WcMethodUseCaseContext, + @Assisted private val method: WcEthMethod.SendTransaction, + override val respondService: WcRespondService, + private val sendTransaction: SendTransactionUseCase, +) : BaseWcSignUseCase(), + WcEthSendTransactionUseCase { + + override val onSign: OnSign = collector@{ state -> + val hash = sendTransaction(state.signModel, wallet, network) + .onLeft { error -> + val sendError = IllegalArgumentException(error.toString()) // todo(wc) use domain error + emit(state.toResult(sendError.left())) + } + .getOrNull() ?: return@collector + val respondHash = if (hash.startsWith(HEX_PREFIX)) hash else HEX_PREFIX + hash + val respondResult = respondService.respond(rawSdkRequest, respondHash) + emit(state.toResult(respondResult)) + } + + override fun updateFee(fee: TransactionFee) { + TODO("Not yet implemented") + } + + override fun invoke(): Flow> { + method + TODO("Not yet implemented") + } + + @AssistedFactory + interface Factory { + fun create( + context: WcMethodUseCaseContext, + method: WcEthMethod.SendTransaction, + ): DefaultWcEthSendTransactionUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSignTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSignTransactionUseCase.kt new file mode 100644 index 0000000000..d8a01823c7 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/DefaultWcEthSignTransactionUseCase.kt @@ -0,0 +1,58 @@ +package com.tangem.data.walletconnect.network.ethereum + +import arrow.core.left +import com.tangem.blockchain.common.HEX_PREFIX +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.extensions.toHexString +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.OnSign +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.domain.transaction.usecase.PrepareForSendUseCase +import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.usecase.ethereum.WcEthSignTransactionUseCase +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow + +internal class DefaultWcEthSignTransactionUseCase @AssistedInject constructor( + override val respondService: WcRespondService, + private val prepareForSend: PrepareForSendUseCase, + @Assisted override val context: WcMethodUseCaseContext, + @Assisted private val method: WcEthMethod.SignTransaction, +) : BaseWcSignUseCase(), + WcEthSignTransactionUseCase { + + override val onSign: OnSign = collector@{ state -> + val hash = prepareForSend(state.signModel, wallet, network) + .onLeft { error -> + emit(state.toResult(error.left())) + } + .getOrNull() ?: return@collector + val hashString = hash.toHexString() + val respondHash = if (hashString.startsWith(HEX_PREFIX)) hashString else HEX_PREFIX + hashString + val respondResult = respondService.respond(rawSdkRequest, respondHash) + emit(state.toResult(respondResult)) + } + + override fun updateFee(fee: TransactionFee) { + TODO("Not yet implemented") + } + + override fun invoke(): Flow> { + method + TODO("Not yet implemented") + } + + @AssistedFactory + interface Factory { + fun create( + context: WcMethodUseCaseContext, + method: WcEthMethod.SignTransaction, + ): DefaultWcEthSignTransactionUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt index 69407681e0..171bb54185 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt @@ -2,57 +2,76 @@ package com.tangem.data.walletconnect.network.ethereum import com.squareup.moshi.Moshi import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.walletconnect.model.CAIP2 import com.tangem.data.walletconnect.model.NamespaceKey -import com.tangem.data.walletconnect.request.WcMethodHandler +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext import com.tangem.data.walletconnect.utils.WcNamespaceConverter -import com.tangem.domain.walletconnect.model.WcMethod -import com.tangem.domain.walletconnect.model.WcRequest -import com.tangem.domain.walletconnect.respond.WcRespondService -import com.tangem.domain.walletconnect.usecase.WcUseCase -import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider -import com.tangem.domain.walletconnect.usecase.ethereum.EthPersonalSignUseCase -import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod -import com.tangem.domain.walletconnect.usecase.ethereum.WcEthMethod.SignMessage -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcEthMethod +import com.tangem.domain.walletconnect.model.WcEthTransactionParams +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import com.tangem.domain.wallets.models.UserWallet +import jakarta.inject.Inject -internal class WcEthNetwork( +internal class WcEthNetwork constructor( private val moshi: Moshi, - private val respondService: WcRespondService, -) : WcMethodHandler, WcUseCasesFlowProvider, WcNamespaceConverter { - - private val _useCases: Channel = Channel(Channel.BUFFERED) - override val useCases = _useCases.receiveAsFlow() + private val excludedBlockchains: ExcludedBlockchains, + private val sessionsManager: WcSessionsManager, + private val factories: Factories, +) : WcRequestToUseCaseConverter, WcNamespaceConverter { override val namespaceKey: NamespaceKey = NamespaceKey("eip155") - override fun canHandle(methodName: String): Boolean { - return Name.entries.find { it.raw == methodName } != null + override suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? { + val methodKey = request.request.method + val name = Name.entries.find { it.raw == methodKey } ?: return null + val method: WcEthMethod = name.toMethod(request) ?: return null + val session = sessionsManager.findSessionByTopic(request.topic) ?: return null + val network = toNetwork(request.chainId.orEmpty(), session.wallet) ?: return null + val context = WcMethodUseCaseContext(session = session, rawSdkRequest = request, network = network) + + return when (method) { + is WcEthMethod.MessageSign -> factories.messageSign.create(context, method) + is WcEthMethod.SendTransaction -> factories.sendTransaction.create(context, method) + is WcEthMethod.SignTransaction -> factories.signTransaction.create(context, method) + } } - override fun deserialize(methodName: String, params: String): WcEthMethod? { - val name = Name.entries.find { it.raw == methodName } ?: return null - return when (name) { - Name.Sign -> TODO() - Name.PersonalSign -> WcMethodHandler.fromJson(params, moshi) + private fun Name.toMethod(request: WcSdkSessionRequest): WcEthMethod? { + val rawParams = request.request.params + return when (this) { + Name.EthSign, + Name.PersonalSign, + -> moshi.fromJson>(rawParams)?.let { list -> + val accountIndex = if (this == Name.EthSign) 0 else 1 + val messageIndex = if (this == Name.EthSign) 1 else 0 + val account = list.getOrNull(accountIndex) ?: return@let null + val message = list.getOrNull(messageIndex) ?: return@let null + WcEthMethod.MessageSign(account = account, message = message) + } Name.SignTypeData -> TODO() Name.SignTypeDataV4 -> TODO() - Name.SignTransaction -> TODO() - Name.SendTransaction -> TODO() + Name.SignTransaction, + Name.SendTransaction, + -> moshi.fromJson>(rawParams) + ?.firstOrNull() + ?.let { + if (this == Name.SignTransaction) { + WcEthMethod.SignTransaction(transaction = it) + } else { + WcEthMethod.SendTransaction(transaction = it) + } + } } } - override fun handle(wcRequest: WcRequest) { - wcRequest as WcRequest - val useCase = when (wcRequest.method) { - is SignMessage -> EthPersonalSignUseCase(wcRequest as WcRequest, respondService) - } - _useCases.trySend(useCase) - } - enum class Name(val raw: String) { - Sign("eth_sign"), + EthSign("eth_sign"), PersonalSign("personal_sign"), SignTypeData("eth_signTypedData"), SignTypeDataV4("eth_signTypedData_v4"), @@ -60,13 +79,18 @@ internal class WcEthNetwork( SendTransaction("eth_sendTransaction"), } + override fun toNetwork(chainId: String, wallet: UserWallet): Network? { + return toNetwork(chainId, wallet, excludedBlockchains) + } + override fun toBlockchain(chainId: CAIP2): Blockchain? { if (chainId.namespace != namespaceKey.key) return null val ethChainId = chainId.reference.toIntOrNull() ?: return null return Blockchain.fromChainId(ethChainId) } - override fun toCAIP2(blockchain: Blockchain): CAIP2? { + override fun toCAIP2(network: Network): CAIP2? { + val blockchain = Blockchain.fromId(network.id.value) if (!blockchain.isEvm()) return null val chainId = blockchain.getChainId() ?: return null return CAIP2( @@ -74,4 +98,10 @@ internal class WcEthNetwork( reference = chainId.toString(), ) } + + internal class Factories @Inject constructor( + val messageSign: DefaultWcEthMessageSignUseCase.Factory, + val sendTransaction: DefaultWcEthSendTransactionUseCase.Factory, + val signTransaction: DefaultWcEthSignTransactionUseCase.Factory, + ) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt index bda63dde43..d4c72c3ee2 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt @@ -1,11 +1,17 @@ package com.tangem.data.walletconnect.network.solana import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.walletconnect.model.CAIP2 import com.tangem.data.walletconnect.model.NamespaceKey import com.tangem.data.walletconnect.utils.WcNamespaceConverter +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet + +internal class WcSolanaNetwork constructor( + private val excludedBlockchains: ExcludedBlockchains, +) : WcNamespaceConverter { -internal class WcSolanaNetwork : WcNamespaceConverter { override val namespaceKey: NamespaceKey = NamespaceKey("solana") override fun toBlockchain(chainId: CAIP2): Blockchain? { @@ -17,7 +23,12 @@ internal class WcSolanaNetwork : WcNamespaceConverter { } } - override fun toCAIP2(blockchain: Blockchain): CAIP2? { + override fun toNetwork(chainId: String, wallet: UserWallet): Network? { + return toNetwork(chainId, wallet, excludedBlockchains) + } + + override fun toCAIP2(network: Network): CAIP2? { + val blockchain = Blockchain.fromId(network.id.value) val chainId = when (blockchain) { Blockchain.Solana -> MAINNET_CHAIN_ID Blockchain.SolanaTestnet -> TESTNET_CHAIN_ID diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt index e24b2641ac..316902dfea 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt @@ -1,92 +1,72 @@ package com.tangem.data.walletconnect.pair import com.reown.walletkit.client.Wallet -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.data.common.currency.getNetwork -import com.tangem.data.walletconnect.model.CAIP2 -import com.tangem.data.walletconnect.model.NamespaceKey import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.walletconnect.model.WcNetwork import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetWalletsUseCase internal class AssociateNetworksDelegate constructor( - private val namespaceConverters: Map, + private val namespaceConverters: Set, private val getWallets: GetWalletsUseCase, private val currenciesRepository: CurrenciesRepository, - private val excludedBlockchains: ExcludedBlockchains, ) { @Throws(WcPairError.UnsupportedNetworks::class) - suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map { + suspend fun associate(sessionProposal: Wallet.Model.SessionProposal): Map { val userWallets = getWallets.invokeSync().filter { it.isMultiCurrency } - val requiredNamespaces: Set = sessionProposal.requiredNamespaces.setOfChainId() - val optionalNamespaces: Set = sessionProposal.optionalNamespaces.setOfChainId() + val requiredNamespaces: Set = sessionProposal.requiredNamespaces.setOfChainId() + val optionalNamespaces: Set = sessionProposal.optionalNamespaces.setOfChainId() - return userWallets.associate { wallet -> - wallet.walletId to mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) - } + return userWallets + .associateWith { wallet -> mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) } } private suspend fun mapNetworksForWallet( wallet: UserWallet, - requiredNamespaces: Set, - optionalNamespaces: Set, + requiredNamespaces: Set, + optionalNamespaces: Set, ): ProposalNetwork { val walletNetworks = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(wallet.walletId) .filterIsInstance() .map { it.network } - val unknownRequired = mutableSetOf() - val missingRequired = mutableSetOf() - val required = mutableSetOf() - val available = mutableSetOf() - val notAdded = mutableSetOf() - - fun CAIP2.toBlockchain() = namespaceConverters[NamespaceKey(this.namespace)]?.toBlockchain(this) - fun Blockchain.toNetwork() = getNetwork( - blockchain = this, - extraDerivationPath = null, - scanResponse = wallet.scanResponse, - excludedBlockchains = excludedBlockchains, - ) + val unknownRequired = mutableSetOf() + val missingRequired = mutableSetOf() + val required = mutableSetOf() + val available = mutableSetOf() + val notAdded = mutableSetOf() requiredNamespaces.forEach { chainId -> - val blockchain = chainId.toBlockchain() - if (blockchain == null) { - unknownRequired.add(WcNetwork.Unknown(missingNetworkName(chainId))) - return@forEach - } - val wcNetwork = blockchain.toNetwork() + val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) } if (wcNetwork == null) { - unknownRequired.add(WcNetwork.Unknown(missingNetworkName(blockchain))) + unknownRequired.add(missingNetworkName(chainId)) return@forEach } val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id } if (walletNetwork == null) { - missingRequired.add(WcNetwork.Supported(wcNetwork)) + missingRequired.add(wcNetwork) } else { - required.add(WcNetwork.Supported(walletNetwork)) + required.add(walletNetwork) } } optionalNamespaces.forEach { chainId -> - val wcNetwork = chainId.toBlockchain()?.toNetwork() ?: return@forEach + val wcNetwork = namespaceConverters.firstNotNullOfOrNull { it.toNetwork(chainId, wallet) } + ?: return@forEach val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id } if (walletNetwork == null) { - available.add(WcNetwork.Supported(wcNetwork)) + available.add(wcNetwork) } else { - notAdded.add(WcNetwork.Supported(walletNetwork)) + notAdded.add(walletNetwork) } } if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedNetworks(unknownRequired) return ProposalNetwork( - walletId = wallet.walletId, + wallet = wallet, missingRequired = missingRequired, required = required, available = available, @@ -94,10 +74,8 @@ internal class AssociateNetworksDelegate constructor( ) } - private fun Map.setOfChainId(): Set = - this.values.flatMap { proposal -> proposal.chains ?: listOf() } - .mapNotNull { rawChainId -> CAIP2.fromRaw(rawChainId) }.toSet() + private fun Map.setOfChainId(): Set = + this.values.flatMap { proposal -> proposal.chains ?: listOf() }.toSet() - private fun missingNetworkName(chainId: CAIP2): String = chainId.namespace.replaceFirstChar(Char::titlecase) - private fun missingNetworkName(blockchain: Blockchain): String = blockchain.getCoinName() + private fun missingNetworkName(chainId: String): String = chainId.replaceFirstChar(Char::titlecase) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt index 98e5e6be32..597bdecefb 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt @@ -1,32 +1,27 @@ package com.tangem.data.walletconnect.pair import com.reown.walletkit.client.Wallet -import com.tangem.blockchain.common.Blockchain import com.tangem.data.walletconnect.model.CAIP10 -import com.tangem.data.walletconnect.model.NamespaceKey import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.domain.tokens.model.Network import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet internal class CaipNamespaceDelegate constructor( - private val namespaceConverters: Map, + private val namespaceConverters: Set, private val walletManagersFacade: WalletManagersFacade, ) { suspend fun associate( sessionProposal: Wallet.Model.SessionProposal, - userWalletId: UserWalletId, + userWallet: UserWallet, networks: List, ): Map { - val converters = namespaceConverters.values - val result = mutableMapOf() networks.map { network -> - val blockchain = Blockchain.fromId(network.id.value) - val address = walletManagersFacade.getDefaultAddress(userWalletId, network) - val chainId = converters.firstOrNull { it.toCAIP2(blockchain) != null }?.toCAIP2(blockchain) + val address = walletManagersFacade.getDefaultAddress(userWallet.walletId, network) + val chainId = namespaceConverters.firstNotNullOfOrNull { it.toCAIP2(network) } requireNotNull(chainId) requireNotNull(address) CAIP10(chainId = chainId, accountAddress = address) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index 65f7b110ee..28776d97c2 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -4,8 +4,6 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.reown.walletkit.client.Wallet -import com.reown.walletkit.client.WalletKit -import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionConverter import com.tangem.domain.walletconnect.model.WcPairError import com.tangem.domain.walletconnect.model.WcSession @@ -15,15 +13,13 @@ import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.pair.WcPairState import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.suspendCancellableCoroutine import timber.log.Timber -import kotlin.coroutines.resume val unsupportedDApps = listOf("dYdX", "dYdX v4", "Apex Pro", "The Sandbox") @@ -31,27 +27,18 @@ internal class DefaultWcPairUseCase( private val sessionsManager: WcSessionsManager, private val associateNetworksDelegate: AssociateNetworksDelegate, private val caipNamespaceDelegate: CaipNamespaceDelegate, -) : WcPairUseCase, WcSdkObserver { + private val sdkDelegate: WcPairSdkDelegate, +) : WcPairUseCase { private val onCallTerminalAction = Channel() - private val onSessionProposal = - Channel>(Channel.BUFFERED) - private val onSessionSettleResponse = Channel(Channel.BUFFERED) override fun pairFlow(uri: String, source: WcPairUseCase.Source): Flow { return flow { emit(WcPairState.Loading) - // call sdk.pair and wait result, finish flow on error - walletKitPair(uri).onLeft { throwable -> - emit(WcPairState.Error(WcPairError.Unknown(throwable.localizedMessage.orEmpty()))) - return@flow - } - // wait for sdk onSessionProposal callback - val (sdkSessionProposal, verifyContext) = onSessionProposal.receiveAsFlow() - .first { (sessionProposal, verifyContext) -> - true // todo(wc) check verifyContext? compare uri? - } + val sdkSessionProposal = sdkDelegate.pair(uri) + .onLeft { emit(WcPairState.Error(it)) } + .getOrNull() ?: return@flow // check unsupported dApps, just local constant for now, finish if unsupported if (sdkSessionProposal.name in unsupportedDApps) { @@ -62,44 +49,32 @@ internal class DefaultWcPairUseCase( } val proposalState = buildProposalState(sdkSessionProposal) - .fold(ifLeft = { WcPairState.Error(it) }, ifRight = { it }) + .onLeft { emit(WcPairState.Error(it)) } + .getOrNull() ?: return@flow emit(proposalState) // wait first terminal action and continue WC pair flow val terminalAction = onCallTerminalAction.receiveAsFlow().first() val sessionForApprove: WcSessionApprove? = when (terminalAction) { is TerminalAction.Approve -> terminalAction.sessionForApprove - TerminalAction.Reject -> { - // non suspending WalletKit.rejectSession call - rejectSession(sdkSessionProposal.proposerPublicKey) - null - } + TerminalAction.Reject -> null } // finish flow if rejected above - sessionForApprove ?: return@flow + if (sessionForApprove == null) { + sdkDelegate.rejectSession(sdkSessionProposal.proposerPublicKey) + return@flow + } // start flow of approving in wc sdk emit(WcPairState.Approving.Loading(sessionForApprove)) - // call sdk approve and wait result val either = walletKitApproveSession( sessionForApprove = sessionForApprove, sdkSessionProposal = sdkSessionProposal, - ).fold( - ifLeft = { WcPairError.ExternalApprovalError(it.localizedMessage.orEmpty()).left() }, - ifRight = { - when (val settledSession = onSessionSettleResponse.receiveAsFlow().first()) { - is Wallet.Model.SettledSessionResponse.Error -> WcPairError.ExternalApprovalError( - settledSession.errorMessage, - ).left() - - is Wallet.Model.SettledSessionResponse.Result -> { - val newSession = settledSession.session.toDomain(sessionForApprove.walletId) - sessionsManager.saveSession(sessionForApprove.walletId, newSession) - newSession.right() - } - } - }, - ) + ).map { settledSession -> + val newSession = settledSession.session.toDomain(sessionForApprove.wallet) + sessionsManager.saveSession(newSession) + newSession + } emit(WcPairState.Approving.Result(sessionForApprove, either)) } } @@ -112,77 +87,20 @@ internal class DefaultWcPairUseCase( onCallTerminalAction.trySend(TerminalAction.Reject) } - override fun onSessionProposal( - sessionProposal: Wallet.Model.SessionProposal, - verifyContext: Wallet.Model.VerifyContext, - ) { - // Triggered when wallet receives the session proposal sent by a Dapp - Timber.i("sessionProposal: $sessionProposal") - onSessionProposal.trySend(sessionProposal to verifyContext) - } - - override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { - // Triggered when wallet receives the session settlement response from Dapp - Timber.i("onSessionSettleResponse: $settleSessionResponse") - onSessionSettleResponse.trySend(settleSessionResponse) - } - - private suspend fun walletKitPair(uri: String): Either = - suspendCancellableCoroutine { continuation -> - WalletKit.pair( - params = Wallet.Params.Pair(uri), - onSuccess = { - Timber.i("Paired successfully: $it") - continuation.resume(Unit.right()) - }, - onError = { - Timber.e("Error while pairing: $it") - continuation.resume(it.throwable.left()) - }, - ) - } - private suspend fun walletKitApproveSession( sessionForApprove: WcSessionApprove, sdkSessionProposal: Wallet.Model.SessionProposal, - ): Either { + ): Either { val namespaces = caipNamespaceDelegate.associate( sdkSessionProposal, - sessionForApprove.walletId, - sessionForApprove.network.map { it.network }, + sessionForApprove.wallet, + sessionForApprove.network, ) val sessionApprove = Wallet.Params.SessionApprove( proposerPublicKey = sdkSessionProposal.proposerPublicKey, namespaces = namespaces, ) - return suspendCancellableCoroutine { continuation -> - WalletKit.approveSession( - params = sessionApprove, - onSuccess = { - Timber.i("Approved successfully: $it") - continuation.resume(Unit.right()) - }, - onError = { - Timber.e("Error while approving: $it") - continuation.resume(it.throwable.left()) - }, - ) - } - } - - private fun rejectSession(proposerPublicKey: String) { - WalletKit.rejectSession( - params = Wallet.Params.SessionReject( - proposerPublicKey = proposerPublicKey, - reason = "", - ), - onSuccess = { - Timber.i("Rejected successfully: $it") - }, - onError = { - Timber.e("Error while rejecting: $it") - }, - ) + return sdkDelegate.approve(sessionApprove) } private suspend fun buildProposalState( @@ -209,8 +127,8 @@ internal class DefaultWcPairUseCase( } },) - private fun Wallet.Model.Session.toDomain(walletId: UserWalletId): WcSession = WcSession( - userWalletId = walletId, + private fun Wallet.Model.Session.toDomain(wallet: UserWallet): WcSession = WcSession( + wallet = wallet, sdkModel = WcSdkSessionConverter.convert(this), ) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt new file mode 100644 index 0000000000..1c11137607 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt @@ -0,0 +1,98 @@ +package com.tangem.data.walletconnect.pair + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit +import com.tangem.data.walletconnect.utils.WcSdkObserver +import com.tangem.domain.walletconnect.model.WcPairError +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +internal class WcPairSdkDelegate : WcSdkObserver { + + private val onSessionProposal = Channel() + private val onSessionSettleResponse = Channel() + + suspend fun pair(url: String): Either = coroutineScope { + suspend fun proposalCallback() = onSessionProposal + .receiveAsFlow() + .filter { proposal -> proposal.url == url } + .first() + + val pairCall = async { sdkPair(url) } + val proposal = async { proposalCallback() } + pairCall.await().map { proposal.await() } + } + + suspend fun approve( + sessionApprove: Wallet.Params.SessionApprove, + ): Either = coroutineScope { + suspend fun approveCallback() = onSessionSettleResponse + .receiveAsFlow() + .first() + + val approveCall = async { sdkApprove(sessionApprove) } + val approveCallback = async { approveCallback() } + approveCall.await() + .onLeft { return@coroutineScope it.left() } + when (val result = approveCallback.await()) { + is Wallet.Model.SettledSessionResponse.Result -> result.right() + is Wallet.Model.SettledSessionResponse.Error -> + WcPairError.ExternalApprovalError(result.errorMessage).left() + } + } + + fun rejectSession(proposerPublicKey: String) { + WalletKit.rejectSession( + params = Wallet.Params.SessionReject( + proposerPublicKey = proposerPublicKey, + reason = "", + ), + onSuccess = {}, + onError = {}, + ) + } + + override fun onSessionProposal( + sessionProposal: Wallet.Model.SessionProposal, + verifyContext: Wallet.Model.VerifyContext, + ) { + // Triggered when wallet receives the session proposal sent by a Dapp + onSessionProposal.trySend(sessionProposal) + } + + override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { + // Triggered when wallet receives the session settlement response from Dapp + onSessionSettleResponse.trySend(settleSessionResponse) + } + + private suspend fun sdkApprove(sessionApprove: Wallet.Params.SessionApprove): Either { + return suspendCancellableCoroutine { continuation -> + WalletKit.approveSession( + params = sessionApprove, + onSuccess = { continuation.resume(Unit.right()) }, + onError = { continuation.resume(it.throwable.toPairError()) }, + ) + } + } + + private suspend fun sdkPair(uri: String): Either { + return suspendCancellableCoroutine { continuation -> + WalletKit.pair( + params = Wallet.Params.Pair(uri), + onSuccess = { continuation.resume(Unit.right()) }, + onError = { continuation.resume(it.throwable.toPairError()) }, + ) + } + } + + private fun Throwable.toPairError() = WcPairError.ExternalApprovalError(this.localizedMessage.orEmpty()).left() +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt index 99f06b1f7a..d4b9fdbf33 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt @@ -3,23 +3,20 @@ package com.tangem.data.walletconnect.request import com.reown.walletkit.client.Wallet import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter -import com.tangem.domain.walletconnect.model.WcMethod -import com.tangem.domain.walletconnect.model.WcRequest -import com.tangem.domain.walletconnect.repository.WcSessionsManager -import com.tangem.domain.walletconnect.request.WcRequestService -import com.tangem.domain.walletconnect.respond.WcRespondService +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch internal class DefaultWcRequestService( - private val sessionsManager: WcSessionsManager, - private val respondService: WcRespondService, - private val requestAdapters: Set>, + private val requestConverters: Set, private val scope: CoroutineScope, -) : WcRequestService, WcSdkObserver { +) : WcSdkObserver, WcUseCasesFlowProvider { - override val requests: MutableSharedFlow> = MutableSharedFlow() + private val _useCases: Channel = Channel(Channel.BUFFERED) + override val useCases = _useCases.receiveAsFlow() override fun onSessionRequest( sessionRequest: Wallet.Model.SessionRequest, @@ -27,21 +24,10 @@ internal class DefaultWcRequestService( ) { // Triggered when a Dapp sends SessionRequest to sign a transaction or a message val sr = WcSdkSessionRequestConverter.convert(sessionRequest) - val method = sr.request.method - val params = sr.request.params scope.launch { - val session = sessionsManager.findSessionByTopic(sr.topic) - val handler: WcMethodHandler? = requestAdapters.firstOrNull { it.canHandle(method) } - - val deserialized: WcMethod? = handler?.deserialize(method, params) - if (handler == null || deserialized == null || session == null) { - respondService.rejectRequest(sr, "UnsupportedMethod") // todo(wc) use our domain error - return@launch - } - - val wcRequest = WcRequest(sr, session, deserialized) - handler.handle(wcRequest) - requests.emit(wcRequest) + val useCase = requestConverters.firstNotNullOfOrNull { it.toUseCase(sr) } + ?: return@launch // todo(wc) handle unsupported + _useCases.trySend(useCase) } } } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcMethodHandler.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcMethodHandler.kt deleted file mode 100644 index b31b707d58..0000000000 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcMethodHandler.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.data.walletconnect.request - -import com.squareup.moshi.Moshi -import com.squareup.moshi.adapter -import com.tangem.domain.walletconnect.model.WcMethod -import com.tangem.domain.walletconnect.model.WcRequest - -interface WcMethodHandler { - fun canHandle(methodName: String): Boolean - fun deserialize(methodName: String, params: String): M? - fun handle(wcRequest: WcRequest) - - companion object { - @OptIn(ExperimentalStdlibApi::class) - inline fun fromJson(params: String, moshi: Moshi): T? = - runCatching { moshi.adapter().fromJsonValue(params) }.getOrNull() - } -} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcRequestToUseCaseConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcRequestToUseCaseConverter.kt new file mode 100644 index 0000000000..624440c0df --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcRequestToUseCaseConverter.kt @@ -0,0 +1,16 @@ +package com.tangem.data.walletconnect.request + +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase + +interface WcRequestToUseCaseConverter { + suspend fun toUseCase(request: WcSdkSessionRequest): WcMethodUseCase? + + companion object { + @OptIn(ExperimentalStdlibApi::class) + inline fun Moshi.fromJson(params: String): T? = + runCatching { this.adapter().fromJson(params) }.getOrNull() + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt index 7381e9b548..025e9834b2 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt @@ -6,7 +6,6 @@ import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest -import com.tangem.domain.walletconnect.respond.WcRespondService import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -50,4 +49,19 @@ internal class DefaultWcRespondService : WcRespondService { }, ) } + + override fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String) { + WalletKit.respondSessionRequest( + params = Wallet.Params.SessionRequestResponse( + sessionTopic = request.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( + id = request.request.id, + code = 0, + message = message, + ), + ), + onSuccess = {}, + onError = {}, + ) + } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/respond/WcRespondService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt similarity index 72% rename from domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/respond/WcRespondService.kt rename to data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt index c74ce0e017..0eb8948d59 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/respond/WcRespondService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.walletconnect.respond +package com.tangem.data.walletconnect.respond import arrow.core.Either import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest @@ -6,4 +6,5 @@ import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest interface WcRespondService { suspend fun respond(request: WcSdkSessionRequest, response: String): Either suspend fun rejectRequest(request: WcSdkSessionRequest, message: String = ""): Either + fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String = "") } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt index 90ccec710d..341a80d075 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -12,7 +12,7 @@ import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionDTO import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.CoroutineScope @@ -36,20 +36,22 @@ internal class DefaultWcSessionsManager constructor( private val onSessionDelete = Channel(capacity = Channel.BUFFERED) private val oneTimeMigration = MutableStateFlow(true) - override val sessions: Flow>> - get() = store.sessions - .transform { inStore -> + override val sessions: Flow>> + get() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore } + .transform { pair -> + val (wallets, inStore) = pair + val inSdk: List = WalletKit.getListOfActiveSessions() if (oneTimeMigration.value) { oneTimeMigration.value = false - val someMigrated = migrateLegacyStore(inStore) + val someMigrated = migrateLegacyStore(inStore, inSdk, wallets) if (someMigrated) return@transform // ignore emit, wait next one } - val inSdk: List = WalletKit.getListOfActiveSessions() - val associatedSessions: List = associateWithSdk(inSdk, inStore) + val associatedSessions: List = associate(inSdk, inStore, wallets) val someRemove = removeUnknownSessions(inStore, associatedSessions) if (someRemove) return@transform // ignore emit, wait next one - emit(associatedSessions.groupBy { it.userWalletId }) + emit(associatedSessions.groupBy { it.wallet }) } + .distinctUntilChanged() .flowOn(dispatchers.io) override fun onWcSdkInit() { @@ -57,11 +59,11 @@ internal class DefaultWcSessionsManager constructor( listenOnSessionDelete() } - override suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) { - store.saveSession(WcSessionDTO(session.sdkModel.topic, session.userWalletId)) + override suspend fun saveSession(session: WcSession) { + store.saveSession(WcSessionDTO(session.sdkModel.topic, session.wallet.walletId)) } - override suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either { + override suspend fun removeSession(session: WcSession): Either { val topic = session.sdkModel.topic val sdkCall = sdkDisconnectSession(topic) sdkCall.onLeft { return it.left() } @@ -80,9 +82,13 @@ internal class DefaultWcSessionsManager constructor( } override suspend fun findSessionByTopic(topic: String): WcSession? = withContext(dispatchers.io) { - val storedSessions = store.findSessionByTopic(topic) ?: return@withContext null + val storedSessions = sessions.firstOrNull() + ?.values?.flatten() + ?.firstOrNull { it.sdkModel.topic == topic } + ?: return@withContext null val sdkSession = WalletKit.getActiveSessionByTopic(topic) ?: return@withContext null - WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession)) + val wallet = storedSessions.wallet + WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession)) } override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { @@ -90,28 +96,35 @@ internal class DefaultWcSessionsManager constructor( onSessionDelete.trySend(sessionDelete) } - private suspend fun migrateLegacyStore(inNewStoreSessions: Set): Boolean { - val walletIds = getWallets.invokeSync().mapTo(mutableSetOf()) { it.walletId } - val inLegacyStoreSessions = walletIds + private suspend fun migrateLegacyStore( + inNewStore: Set, + inSdk: List, + wallets: List, + ): Boolean { + val walletIds = wallets.map { wallet -> wallet.walletId } + val inLegacyStore = walletIds .map { walletId -> flow { emit(legacyStore.loadSessions(walletId.stringValue).map { WcSessionDTO(it.topic, walletId) }) } } .merge() .reduce { accumulator, value -> accumulator.plus(value) } + // migrate only active legacySessions + .filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } } - val mustSaveInNewStore = inLegacyStoreSessions.subtract(inNewStoreSessions) + val mustSaveInNewStore = inLegacyStore.subtract(inNewStore) if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore) return mustSaveInNewStore.isNotEmpty() } - private fun associateWithSdk( - sdkSessions: List, - storeSessions: Set, + private fun associate( + inSdk: List, + inStore: Set, + wallets: List, ): List { - val wcSessions = sdkSessions.mapNotNull { sdkSession -> - val storedSessions = storeSessions.find { it.topic == sdkSession.topic } - ?: return@mapNotNull null - WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession)) + val wcSessions = inStore.mapNotNull { session -> + val wallet = wallets.find { it.walletId == session.walletId } ?: return@mapNotNull null + val sdkSession = inSdk.find { it.topic == session.topic } ?: return@mapNotNull null + WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession)) } return wcSessions } @@ -122,9 +135,6 @@ internal class DefaultWcSessionsManager constructor( val haveSomeUnknown = unknownStoredSessions.isNotEmpty() if (haveSomeUnknown) { - unknownStoredSessions.forEach { unknown -> - legacyStore.removeSession(unknown.walletId.stringValue, unknown.topic) - } store.removeSessions(unknownStoredSessions.toSet()) } return haveSomeUnknown diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BaseWcSignUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BaseWcSignUseCase.kt new file mode 100644 index 0000000000..d4e8696371 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BaseWcSignUseCase.kt @@ -0,0 +1,70 @@ +package com.tangem.data.walletconnect.sign + +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase +import com.tangem.domain.wallets.models.UserWallet +import kotlinx.coroutines.flow.FlowCollector + +internal abstract class BaseWcSignUseCase : + WcMethodUseCase, + WcSignUseCase, + FinalActionCollector, + MiddleActionCollector { + + abstract val respondService: WcRespondService + + abstract val context: WcMethodUseCaseContext + override val network: Network get() = context.network + override val session: WcSession get() = context.session + override val rawSdkRequest: WcSdkSessionRequest get() = context.rawSdkRequest + val wallet: UserWallet get() = session.wallet + + protected val delegate by lazy { + WcSignUseCaseDelegate( + finalActionCollector = this, + middleActionCollector = this, + ) + } + + override val onCancel: suspend (currentState: WcSignState) -> Unit = { + defaultReject() + } + + override fun sign() = delegate.sign() + override fun cancel() = delegate.cancel() + protected fun middleAction(action: MiddleAction) = delegate.middleAction(action) + + protected fun defaultReject() { + respondService.rejectRequestNonBlock(rawSdkRequest) + } +} + +internal interface MiddleActionCollector { + + val onMiddleAction: OnMiddle get() = { _, _ -> } +} + +internal interface FinalActionCollector { + + val onSign: OnSign get() = {} + + val onCancel: OnCancel get() = {} +} + +internal class WcMethodUseCaseContext( + val session: WcSession, + val rawSdkRequest: WcSdkSessionRequest, + val network: Network, +) + +internal typealias OnSign = + suspend FlowCollector>.(state: WcSignState) -> Unit +internal typealias OnCancel = + suspend (currentState: WcSignState) -> Unit +internal typealias OnMiddle = + suspend FlowCollector.(currentState: WcSignState, middleAction: MiddleAction) -> Unit \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt new file mode 100644 index 0000000000..f77af76789 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.data.walletconnect.sign + +import arrow.core.Either +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep + +object SignStateConverter { + + internal fun preSign(signModel: M) = WcSignState(signModel, WcSignStep.PreSign) + internal fun signing(signModel: M) = WcSignState(signModel, WcSignStep.Signing) + internal fun result(result: Either, signModel: M) = + WcSignState(signModel, WcSignStep.Result(result)) + + internal fun WcSignState.toPreSign(signModel: M = this.signModel) = copy( + signModel = signModel, + domainStep = WcSignStep.PreSign, + ) + + internal fun WcSignState.toSigning(signModel: M = this.signModel) = copy( + domainStep = WcSignStep.Signing, + signModel = signModel, + ) + + internal fun WcSignState.toResult(result: Either, signModel: M = this.signModel) = copy( + domainStep = WcSignStep.Result(result), + signModel = signModel, + ) +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt new file mode 100644 index 0000000000..c84bc39ac7 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt @@ -0,0 +1,91 @@ +package com.tangem.data.walletconnect.sign + +import arrow.core.left +import com.tangem.data.walletconnect.sign.SignStateConverter.toPreSign +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +internal class WcSignUseCaseDelegate( + private val finalActionCollector: FinalActionCollector, + private val middleActionCollector: MiddleActionCollector, +) : FinalActionCollector by finalActionCollector, + MiddleActionCollector by middleActionCollector { + + private val middleActionsChannel = Channel() + private val finalActionsChannel = Channel() + + fun cancel() { + finalActionsChannel.trySend(Action.Cancel) + } + + fun sign() { + finalActionsChannel.trySend(Action.Sign) + } + + fun middleAction(action: MiddleAction) { + middleActionsChannel.trySend(action) + } + + operator fun invoke(initModel: SignModel) = channelFlow { + val state = MutableStateFlow(WcSignState(initModel, WcSignStep.PreSign)) + + state + .onEach { newState -> channel.send(newState) } + .launchIn(this) + + fun listenMiddle() = middleActionsChannel.receiveAsFlow() + .buffer() + .transform { middleActions -> this.onMiddleAction(state.value, middleActions) } + .onEach { updatedModel -> state.update { it.toPreSign(updatedModel) } } + .launchIn(this) + + var listenMiddleJob: Job = listenMiddle() + + fun signFlow() = flow { onSign(state.updateAndGet { it.toSigning() }) } + .onEach { newState -> state.update { newState } } + .catch { exception -> + val errorResult = state.value.toResult(exception.left()) + state.update { errorResult } + } + + var signJob: Job? = null + + finalActionsChannel.receiveAsFlow() + .transformLatest { finalAction -> + when (finalAction) { + Action.Cancel -> { + onCancel.invoke(state.value) + channel.close() + } + Action.Sign -> { + val isSigningNow = signJob?.isActive == true + if (isSigningNow) return@transformLatest + listenMiddleJob.cancel() + signJob = launch { + signFlow().collect() + listenMiddleJob = listenMiddle() + } + } + } + } + .launchIn(this) + + /** + * keep flow running to attempt re-signing after an error + * or do something after a successful sign + */ + awaitClose() + } + + sealed interface Action { + data object Cancel : Action + data object Sign : Action + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt index bb1e86298b..a95ea4604b 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt @@ -1,12 +1,31 @@ package com.tangem.data.walletconnect.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.getNetwork import com.tangem.data.walletconnect.model.CAIP2 import com.tangem.data.walletconnect.model.NamespaceKey +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet internal interface WcNamespaceConverter { val namespaceKey: NamespaceKey + fun toBlockchain(chainId: CAIP2): Blockchain? - fun toCAIP2(blockchain: Blockchain): CAIP2? + fun toBlockchain(chainId: String): Blockchain? = toCAIP2(chainId)?.let { caip2 -> toBlockchain(caip2) } + + fun toCAIP2(network: Network): CAIP2? + fun toCAIP2(chainId: String): CAIP2? = CAIP2.fromRaw(chainId) + + fun toNetwork(chainId: String, wallet: UserWallet): Network? + fun toNetwork(chainId: String, wallet: UserWallet, excludedBlockchains: ExcludedBlockchains): Network? { + val blockchain = toBlockchain(chainId) ?: return null + return getNetwork( + blockchain = blockchain, + extraDerivationPath = null, + scanResponse = wallet.scanResponse, + excludedBlockchains = excludedBlockchains, + ) + } } \ No newline at end of file diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt new file mode 100644 index 0000000000..86ed85e03e --- /dev/null +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt @@ -0,0 +1,232 @@ +package com.tangem.domain.walletconnect + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import com.reown.walletkit.client.Wallet +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate +import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate +import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase +import com.tangem.data.walletconnect.pair.WcPairSdkDelegate +import com.tangem.data.walletconnect.utils.WcSdkSessionConverter +import com.tangem.domain.walletconnect.model.WcPairError +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.WcSessionApprove +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.usecase.pair.WcPairState +import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class DefaultWcPairUseCaseTest { + + private val sessionsManager: WcSessionsManager = mockk() + private val associateNetworksDelegate: AssociateNetworksDelegate = mockk() + private val caipNamespaceDelegate: CaipNamespaceDelegate = mockk() + private val sdkDelegate: WcPairSdkDelegate = mockk() + + private val url = "testUrl" + private val source = WcPairUseCase.Source.QR + private val loading = WcPairState.Loading + private val sdkProposal: Wallet.Model.SessionProposal + get() = Wallet.Model.SessionProposal( + pairingTopic = "", + name = "", + description = "", + url = "", + icons = listOf(), + redirect = "", + requiredNamespaces = mapOf(), + optionalNamespaces = mapOf(), + properties = mapOf(), + proposerPublicKey = "", + relayProtocol = "", + relayData = "", + ) + + private val unsupportedDApp = "Apex Pro" + private val unsupportedSdkProposal get() = sdkProposal.copy(name = unsupportedDApp) + + private val sessionForApprove: WcSessionApprove + get() = WcSessionApprove( + wallet = MockUserWalletFactory.create(), + network = listOf(), + ) + + private val sdkApprove: Wallet.Params.SessionApprove + get() = Wallet.Params.SessionApprove( + proposerPublicKey = "", + namespaces = mapOf(), + ) + + private val sdkApproveSuccess: Wallet.Model.SettledSessionResponse.Result + get() = Wallet.Model.SettledSessionResponse.Result( + session = sdkSession, + ) + + private val sdkSession: Wallet.Model.Session + get() = Wallet.Model.Session( + pairingTopic = "", + topic = "", + expiry = 0L, + requiredNamespaces = mapOf(), + optionalNamespaces = mapOf(), + namespaces = mapOf(), + metaData = null, + ) + + private val Wallet.Model.Session.sessionForSave: WcSession + get() = WcSession( + wallet = sessionForApprove.wallet, + sdkModel = WcSdkSessionConverter.convert(this), + ) + + private val useCase = DefaultWcPairUseCase( + sessionsManager = sessionsManager, + associateNetworksDelegate = associateNetworksDelegate, + caipNamespaceDelegate = caipNamespaceDelegate, + sdkDelegate = sdkDelegate, + ) + + @Before + fun setup() { + coEvery { associateNetworksDelegate.associate(sdkProposal) } returns mapOf() + coEvery { + caipNamespaceDelegate.associate( + sessionProposal = sdkProposal, + userWallet = sessionForApprove.wallet, + networks = sessionForApprove.network, + ) + } returns mapOf() + } + + @Test + fun `pair, emmit proposal state and wait actions`() = runTest { + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assert(awaitItem() is WcPairState.Proposal) + expectNoEvents() + } + } + + @Test + fun `success pair and approve flow`() = runTest { + val approveLoading = WcPairState.Approving.Loading(sessionForApprove) + val sessionForSave = sdkSession.sessionForSave + val result = WcPairState.Approving.Result(sessionForApprove, sessionForSave.right()) + + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.approve(sdkApprove) } returns sdkApproveSuccess.right() + coEvery { sessionsManager.saveSession(sessionForSave) } returns Unit + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.approve(sessionForApprove) + // ignore + useCase.reject() + + assertEquals(approveLoading, awaitItem()) + coVerifyOrder { + sdkDelegate.approve(sdkApprove) + sessionsManager.saveSession(sessionForSave) + } + assertEquals(result, awaitItem()) + awaitComplete() + } + } + + @Test + fun `success pair and reject approving`() = runTest { + val proposerPublicKey = sdkProposal.proposerPublicKey + + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.rejectSession(proposerPublicKey) } returns Unit + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.reject() + coVerifyOrder { + sdkDelegate.rejectSession(sdkProposal.proposerPublicKey) + } + awaitComplete() + } + } + + @Test + fun `success pair and reject unsupported dApp`() = runTest { + coEvery { sdkDelegate.pair(url) } returns unsupportedSdkProposal.right() + val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDApp) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assertEquals(unsupportedDAppError, awaitItem()) + awaitComplete() + } + } + + @Test + fun `complete on pair error`() = runTest { + val error = WcPairError.ExternalApprovalError("error") + coEvery { sdkDelegate.pair(url) } returns error.left() + val errorState = WcPairState.Error(error) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assertEquals(errorState, awaitItem()) + awaitComplete() + } + } + + @Test + fun `complete on approve error`() = runTest { + val approveLoading = WcPairState.Approving.Loading(sessionForApprove) + val error = WcPairError.ExternalApprovalError("error").left() + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.approve(sdkApprove) } returns error + + val errorResult = WcPairState.Approving.Result(sessionForApprove, error) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + associateNetworksDelegate.associate(sdkProposal) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.approve(sessionForApprove) + + assertEquals(approveLoading, awaitItem()) + coVerifyOrder { + sdkDelegate.approve(sdkApprove) + } + + assertEquals(errorResult, awaitItem()) + awaitComplete() + } + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt new file mode 100644 index 0000000000..e80bd9a4f8 --- /dev/null +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt @@ -0,0 +1,254 @@ +package com.tangem.domain.walletconnect + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import com.tangem.data.walletconnect.sign.FinalActionCollector +import com.tangem.data.walletconnect.sign.MiddleActionCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning +import com.tangem.data.walletconnect.sign.WcSignUseCaseDelegate +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class WcSignUseCaseDelegateTest { + + private val middleActionCollector = mockk>() + private val finalActionCollector = mockk>() + private val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + private val initSignModel = TestSignModel() + + private val initState = WcSignState(initSignModel, WcSignStep.PreSign) + private val signing = initState.toSigning() + private val result = signing.toResult(Unit.right()) + private val testException = RuntimeException("test") + + private val successSign: suspend FlowCollector>.( + currentState: WcSignState, + ) -> Unit = { state -> + delay(2) + emit(state.toResult(Unit.right())) + } + + private val failedSign: suspend FlowCollector>.( + currentState: WcSignState, + ) -> Unit + get() = { state -> + delay(2) + emit(state.toResult(testException.left())) + } + + @Before + fun setup() { + every { middleActionCollector.onMiddleAction } returns { _, _ -> } + every { finalActionCollector.onSign } returns { } + every { finalActionCollector.onCancel } returns { } + } + + @Test + fun `invoke and keep flow running`() = runTest { + every { finalActionCollector.onSign } returns successSign + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `success sign, keep flow running`() = runTest { + every { finalActionCollector.onSign } returns successSign + + useCase.invoke(initModel = initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(result, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `failed sign, keep flow running`() = runTest { + val failedResult = signing.toResult(testException.left()) + every { finalActionCollector.onSign } returns failedSign + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(failedResult, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `failed sign and catch unknown exception`() = runTest { + val exception = RuntimeException("asd") + val expectedErrorState = signing.toResult(exception.left()) + every { finalActionCollector.onSign } returns { + delay(2) + throw exception + } + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(expectedErrorState, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `interrupt signing and complete flow on cancel call`() = runTest { + every { finalActionCollector.onSign } returns { + delay(5) + emit(result) + } + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + delay(2) + useCase.cancel() + awaitComplete() + } + } + + @Test + fun `ignore multi time sign call till signed`() = runTest { + var count = 0 + val startLoading = WcSignState(TestSignModel("startLoading 1"), WcSignStep.Signing) + val startLoading2 = WcSignState(TestSignModel("startLoading 2"), WcSignStep.Signing) + val expectedSignResult = result + + every { finalActionCollector.onSign } returns { + // should emit single time in this test + emit(if (count % 2 == 0) startLoading else startLoading2) + count = count.inc() + delay(10) + emit(expectedSignResult) + } + + useCase.invoke(initSignModel).test { + useCase.sign() + delay(2) + assertEquals(startLoading, expectMostRecentItem()) + + // should ignore + useCase.sign() + delay(2) + expectNoEvents() + + // should ignore + useCase.sign() + expectNoEvents() + + delay(8) + assertEquals(expectedSignResult, expectMostRecentItem()) + expectNoEvents() + } + } + + @Test + fun `ignore middle actions while signing, on failed collect middle actions again`() = runTest { + val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr) + val firstMiddleUpdate = WcSignState( + signModel = firstTextMode, + domainStep = WcSignStep.PreSign, + ) + val startLoading = firstMiddleUpdate.toSigning() + val failedSign = startLoading.toResult(testException.left()) + val thirdMiddleUpdate = WcSignState( + signModel = TestSignModel(TestMiddleAction.Three().newTestStr), + domainStep = WcSignStep.PreSign, + ) + + every { finalActionCollector.onSign } returns { + delay(6) + emit(failedSign) + } + + every { middleActionCollector.onMiddleAction } returns { currentState, middleAction -> + emit(currentState.signModel.copy(testStr = middleAction.newTestStr)) + } + + useCase.invoke(initSignModel).test { + delay(2) + useCase.middleAction(TestMiddleAction.One()) + assertEquals(firstMiddleUpdate, expectMostRecentItem()) + + useCase.sign() + assertEquals(startLoading, awaitItem()) + + // should ignore + delay(2) + useCase.middleAction(TestMiddleAction.Two()) + + delay(3) + assertEquals(failedSign, awaitItem()) + + // continue listen + delay(2) + useCase.middleAction(TestMiddleAction.Three()) + assertEquals(thirdMiddleUpdate, awaitItem()) + } + } + + @Test + fun `buffered middle actions and drop on sign call`() = runTest { + val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr) + val expectedFirst = initState.copy(signModel = firstTextMode) + val expectedSecond = initState.copy(signModel = TestSignModel(TestMiddleAction.Two().newTestStr)) + + every { finalActionCollector.onSign } returns successSign + every { middleActionCollector.onMiddleAction } returns { currentState, middleAction -> + emit(currentState.signModel.copy(testStr = middleAction.newTestStr)) + delay(4) + } + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.middleAction(TestMiddleAction.One()) + useCase.middleAction(TestMiddleAction.Two()) + // must be dropped + useCase.middleAction(TestMiddleAction.Three()) + + // 0 - 4 -> "one" is emitted + // 4 - 8 -> "two" is emitted + // 8 - 12 -> "Signing" is emitted, "three" ignored + delay(2) + assertEquals(expectedFirst, awaitItem()) + delay(4) + assertEquals(expectedSecond, awaitItem()) + + useCase.sign() + delay(4) + assertEquals(expectedSecond.toSigning(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + internal data class TestSignModel(val testStr: String = "testStr") + + internal sealed interface TestMiddleAction { + val newTestStr: String + + data class One(override val newTestStr: String = "Middle Action One") : TestMiddleAction + data class Two(override val newTestStr: String = "Middle Action Two") : TestMiddleAction + data class Three(override val newTestStr: String = "Middle Action Three") : TestMiddleAction + } +} \ No newline at end of file diff --git a/domain/blockaid/.gitignore b/domain/blockaid/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/blockaid/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/blockaid/build.gradle.kts b/domain/blockaid/build.gradle.kts new file mode 100644 index 0000000000..9fda0da981 --- /dev/null +++ b/domain/blockaid/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + /* Project - Domain */ + implementation(projects.domain.core) + implementation(projects.domain.blockaid.models) + + /* Other */ + implementation(deps.moshi.adapters) +} \ No newline at end of file diff --git a/domain/blockaid/models/.gitignore b/domain/blockaid/models/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/blockaid/models/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/blockaid/models/build.gradle.kts b/domain/blockaid/models/build.gradle.kts new file mode 100644 index 0000000000..14f878155b --- /dev/null +++ b/domain/blockaid/models/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.ksp) + id("configuration") +} +dependencies { + /* Other */ + implementation(deps.moshi) + ksp(deps.moshi.kotlin.codegen) +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/CheckDAppResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/CheckDAppResult.kt new file mode 100644 index 0000000000..480b3d8fd9 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/CheckDAppResult.kt @@ -0,0 +1,22 @@ +package com.domain.blockaid.models.dapp + +/** + * Result of BlockAid's DApp domain check + */ +enum class CheckDAppResult { + + /** + * DApp was confirmed safe + */ + SAFE, + + /** + * DApp was confirmed unsafe (known security risk) + */ + UNSAFE, + + /** + * Check wasn't performed, BlockAid cannot guarantee DApp's safety + */ + FAILED_TO_VERIFY, +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/DAppData.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/DAppData.kt new file mode 100644 index 0000000000..25d1882031 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/dapp/DAppData.kt @@ -0,0 +1,11 @@ +package com.domain.blockaid.models.dapp + +/** + * Data BlockAid needs to verify DApp domain + * + * @property url DApp's domain url + */ +@JvmInline +value class DAppData( + val url: String, +) \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt new file mode 100644 index 0000000000..d7244d6023 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/CheckTransactionResult.kt @@ -0,0 +1,12 @@ +package com.domain.blockaid.models.transaction + +/** + * Result of BlockAid's transaction check + * + * @property validation Indicates whether the transaction is considered safe or unsafe + * @property simulation Provides insight into the expected outcome of the transaction + */ +data class CheckTransactionResult( + val validation: ValidationResult, + val simulation: SimulationResult, +) \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/SimulationResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/SimulationResult.kt new file mode 100644 index 0000000000..c44441a922 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/SimulationResult.kt @@ -0,0 +1,21 @@ +package com.domain.blockaid.models.transaction + +import com.domain.blockaid.models.transaction.simultation.SimulationData + +/** + * Result of BlockAid's transaction simulation + */ +sealed class SimulationResult { + + /** + * Simulation was successfully performed and returned data + */ + data class Success( + val data: SimulationData, + ) : SimulationResult() + + /** + * Simulation wasn't performed, BlockAid cannot guarantee transaction's behavior + */ + data object FailedToSimulate : SimulationResult() +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt new file mode 100644 index 0000000000..01fb3a9321 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt @@ -0,0 +1,36 @@ +package com.domain.blockaid.models.transaction + +/** + * Input data for BlockAid's transaction check + * + * @property chain Chain name, for ex. "ethereum" + * @property accountAddress The address of the account (wallet) received the request in hex string format + * @property method Transaction method, for ex. "eth_signTransaction" + * @property domainUrl Url of the DApp domain + */ +data class TransactionData( + val chain: String, + val accountAddress: String, + val method: String, + val domainUrl: String, + val params: TransactionParams, +) + +sealed class TransactionParams { + + /** + * Parameters for Ethereum based transactions, can be taken from [WcSdkSessionRequest.JSONRPCRequest.params] + */ + data class Evm( + val params: String, + ) : TransactionParams() + + /** + * Parameters for Solana transactions + * + * @property transactions Base64-encoded serialized list of transactions + */ + data class Solana( + val transactions: List, + ) : TransactionParams() +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/ValidationResult.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/ValidationResult.kt new file mode 100644 index 0000000000..0adde02aa8 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/ValidationResult.kt @@ -0,0 +1,22 @@ +package com.domain.blockaid.models.transaction + +/** + * Result of BlockAid's transaction validation + */ +enum class ValidationResult { + + /** + * Transaction was confirmed safe + */ + SAFE, + + /** + * Transaction was confirmed unsafe + */ + UNSAFE, + + /** + * Validation wasn't performed, BlockAid cannot guarantee transaction's safety + */ + FAILED_TO_VALIDATE, +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/AmountInfo.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/AmountInfo.kt new file mode 100644 index 0000000000..14e8b4c6d6 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/AmountInfo.kt @@ -0,0 +1,8 @@ +package com.domain.blockaid.models.transaction.simultation + +import java.math.BigDecimal + +data class AmountInfo( + val amount: BigDecimal, + val token: TokenInfo, +) \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/ApprovedAmount.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/ApprovedAmount.kt new file mode 100644 index 0000000000..6fdad58ec2 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/ApprovedAmount.kt @@ -0,0 +1,9 @@ +package com.domain.blockaid.models.transaction.simultation + +import java.math.BigDecimal + +data class ApprovedAmount( + val approvedAmount: BigDecimal, + val isUnlimited: Boolean, + val tokenInfo: TokenInfo, +) \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/SimulationData.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/SimulationData.kt new file mode 100644 index 0000000000..afd8fea9e9 --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/SimulationData.kt @@ -0,0 +1,22 @@ +package com.domain.blockaid.models.transaction.simultation + +/** + * Result of a successful transaction simulation. + */ +sealed class SimulationData { + + /** + * Represents a swap/send/sell operations with specified send and receive amounts (can be multiple amounts for NFT) + */ + data class SendAndReceive( + val send: List, + val receive: List, + ) : SimulationData() + + /** + * Represents an approve operation with the specified amount (can be multiple amounts for NFT) + */ + data class Approve( + val approvedAmounts: List, + ) : SimulationData() +} \ No newline at end of file diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/TokenInfo.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/TokenInfo.kt new file mode 100644 index 0000000000..889bcf1d1e --- /dev/null +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/simultation/TokenInfo.kt @@ -0,0 +1,7 @@ +package com.domain.blockaid.models.transaction.simultation + +data class TokenInfo( + val chainId: Int?, + val logoUrl: String?, + val symbol: String, +) \ No newline at end of file diff --git a/domain/blockaid/src/main/kotlin/com/tangem/domain/blockaid/BlockAidVerifier.kt b/domain/blockaid/src/main/kotlin/com/tangem/domain/blockaid/BlockAidVerifier.kt new file mode 100644 index 0000000000..e41f57e783 --- /dev/null +++ b/domain/blockaid/src/main/kotlin/com/tangem/domain/blockaid/BlockAidVerifier.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.blockaid + +import arrow.core.Either +import com.domain.blockaid.models.dapp.CheckDAppResult +import com.domain.blockaid.models.dapp.DAppData +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.TransactionData + +/** + * Verifies the safety of DApps and WalletConnect transactions + */ +interface BlockAidVerifier { + + /** + * Checks if a DApp is safe to use + */ + suspend fun verifyDApp(data: DAppData): Either + + /** + * Checks the safety of a WalletConnect transaction and provides a simulation result + */ + suspend fun verifyTransaction(data: TransactionData): Either +} \ No newline at end of file diff --git a/domain/demo/src/main/java/com/tangem/domain/demo/DemoTransactionSender.kt b/domain/demo/src/main/java/com/tangem/domain/demo/DemoTransactionSender.kt index 5c667f1f53..8ed8931539 100644 --- a/domain/demo/src/main/java/com/tangem/domain/demo/DemoTransactionSender.kt +++ b/domain/demo/src/main/java/com/tangem/domain/demo/DemoTransactionSender.kt @@ -1,6 +1,7 @@ package com.tangem.domain.demo import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.transaction.TransactionSendResult @@ -24,8 +25,12 @@ class DemoTransactionSender(private val walletManager: WalletManager) : Transact ) } - override suspend fun estimateFee(amount: Amount, destination: String): Result { - return getFee(amount, walletManager.wallet.address) + override suspend fun estimateFee( + amount: Amount, + destination: String, + callData: SmartContractCallData?, + ): Result { + return getFee(amount, walletManager.wallet.address, callData) } override suspend fun send( diff --git a/domain/feedback/build.gradle.kts b/domain/feedback/build.gradle.kts index 28fe483e00..213c40c655 100644 --- a/domain/feedback/build.gradle.kts +++ b/domain/feedback/build.gradle.kts @@ -15,4 +15,5 @@ dependencies { implementation(projects.core.res) implementation(projects.domain.models) implementation(projects.domain.wallets.models) + implementation(projects.domain.visa.models) } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index c2726a604f..7d0f5aa49f 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -2,12 +2,43 @@ package com.tangem.domain.feedback import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.utils.breakLine +import com.tangem.domain.visa.model.VisaTxDetails import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainAddresses internal class FeedbackDataBuilder { private val builder = StringBuilder() + fun addVisaTxInfo(txDetails: VisaTxDetails) { + builder.appendKeyValue("Type", txDetails.type) + builder.appendKeyValue("Status", txDetails.status) + builder.appendKeyValue("Blockchain amount", txDetails.blockchainAmount.toString()) + builder.appendKeyValue("Transaction amount", txDetails.transactionAmount.toString()) + builder.appendKeyValue("Currency code", txDetails.transactionCurrencyCode.toString()) + builder.appendKeyValue("Merchant name", txDetails.merchantName) + builder.appendKeyValue("Merchant city", txDetails.merchantCity) + builder.appendKeyValue("Merchant country code", txDetails.merchantCountryCode) + builder.appendKeyValue("Merchant category code", txDetails.merchantCategoryCode) + + builder.appendDelimiter() + builder.breakLine() + builder.append("Requests:") + + txDetails.requests.forEach { request -> + builder.appendKeyValue("Type", request.requestType) + builder.appendKeyValue("Status", request.requestStatus) + builder.appendKeyValue("Blockchain amount", request.blockchainAmount.toString()) + builder.appendKeyValue("Transaction amount", request.transactionAmount.toString()) + builder.appendKeyValue("Currency code", request.txCurrencyCode.toString()) + builder.appendKeyValue("Error code", request.errorCode.toString()) + builder.appendKeyValue("Date", request.requestDate.toString()) + builder.appendKeyValue("Transaction hash", request.txHash) + builder.appendKeyValue("Transaction status", request.txStatus) + builder.appendDelimiter() + builder.breakLine() + } + } + fun addUserWalletsInfo(userWalletsInfo: UserWalletsInfo) { builder.appendKeyValue("User Wallet ID", userWalletsInfo.selectedUserWalletId) builder.appendKeyValue("Total saved wallets", userWalletsInfo.totalUserWallets.toString()) diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt index 7bb6e53a9b..a94664bc9a 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt @@ -2,7 +2,6 @@ package com.tangem.domain.feedback import android.content.res.Resources import com.tangem.core.res.getStringSafe -import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmail import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackRepository @@ -27,7 +26,7 @@ class SendFeedbackEmailUseCase( suspend operator fun invoke(type: FeedbackEmailType) { val email = FeedbackEmail( - address = getAddress(type.cardInfo), + address = getAddress(type), subject = emailSubjectResolver.resolve(type), message = createMessage(type), // Temporally user data is not sent @@ -37,8 +36,12 @@ class SendFeedbackEmailUseCase( feedbackRepository.sendEmail(email) } - private fun getAddress(cardInfo: CardInfo?): String { - return if (cardInfo?.isStart2Coin == true) START2COIN_SUPPORT_EMAIL else TANGEM_SUPPORT_EMAIL + private fun getAddress(type: FeedbackEmailType): String { + return when { + type is FeedbackEmailType.Visa || type.cardInfo?.isVisa == true -> TANGEM_VISA_SUPPORT_EMAIL + type.cardInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL + else -> TANGEM_SUPPORT_EMAIL + } } private suspend fun createMessage(type: FeedbackEmailType): String { @@ -61,12 +64,15 @@ class SendFeedbackEmailUseCase( is FeedbackEmailType.CurrencyDescriptionError, is FeedbackEmailType.PreActivatedWallet, is FeedbackEmailType.CardAttestationFailed, + is FeedbackEmailType.Visa.Dispute, -> this is FeedbackEmailType.DirectUserRequest, is FeedbackEmailType.RateCanBeBetter, is FeedbackEmailType.StakingProblem, is FeedbackEmailType.SwapProblem, is FeedbackEmailType.TransactionSendingProblem, + is FeedbackEmailType.Visa.Activation, + is FeedbackEmailType.Visa.DirectUserRequest, -> { append(resources.getStringSafe(R.string.feedback_data_collection_message)) skipLine() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt index 681cb957c0..d6d83ea329 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt @@ -11,6 +11,7 @@ data class CardInfo( val signedHashesList: List, val isImported: Boolean, val isStart2Coin: Boolean, + val isVisa: Boolean, ) { data class SignedHashes(val curve: String, val total: String?) diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 364c363194..7853427ca5 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -1,5 +1,7 @@ package com.tangem.domain.feedback.models +import com.tangem.domain.visa.model.VisaTxDetails + /** * Email feedback type * @@ -52,4 +54,15 @@ sealed interface FeedbackEmailType { data object CardAttestationFailed : FeedbackEmailType { override val cardInfo: CardInfo? = null } + + sealed class Visa : FeedbackEmailType { + data class DirectUserRequest(override val cardInfo: CardInfo) : Visa() + + data class Activation(override val cardInfo: CardInfo) : Visa() + + data class Dispute( + val visaTxDetails: VisaTxDetails, + override val cardInfo: CardInfo, + ) : Visa() + } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index e531f71d3f..7711abe1d1 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -4,6 +4,7 @@ import com.tangem.domain.feedback.FeedbackDataBuilder import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.visa.model.VisaTxDetails /** * Email message body resolver @@ -29,11 +30,20 @@ internal class EmailMessageBodyResolver( is FeedbackEmailType.ScanningProblem, is FeedbackEmailType.CardAttestationFailed, -> addPhoneInfoBody() + is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.cardInfo) + is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.cardInfo) + is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.cardInfo, type.visaTxDetails) } return build() } + private suspend fun FeedbackDataBuilder.addVisaRequestBody(cardInfo: CardInfo, visaTxDetails: VisaTxDetails) { + addUserRequestBody(cardInfo) + addDelimiter() + addVisaTxInfo(visaTxDetails) + } + private suspend fun FeedbackDataBuilder.addUserRequestBody(cardInfo: CardInfo) { addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo(cardInfo.userWalletId)) addDelimiter() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt index 30058b611e..1708558099 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt @@ -20,6 +20,9 @@ internal class EmailMessageTitleResolver(private val resources: Resources) { is FeedbackEmailType.DirectUserRequest, is FeedbackEmailType.CurrencyDescriptionError, is FeedbackEmailType.CardAttestationFailed, + is FeedbackEmailType.Visa.Activation, + is FeedbackEmailType.Visa.DirectUserRequest, + is FeedbackEmailType.Visa.Dispute, -> R.string.feedback_preface_support is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index cc2b6c379c..849d1ce0c4 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -37,6 +37,9 @@ internal class EmailSubjectResolver(private val resources: Resources) { resources.getStringSafe(R.string.feedback_token_description_error) } FeedbackEmailType.CardAttestationFailed -> "Card attestation failed" + is FeedbackEmailType.Visa.Activation -> "[Visa] [Activation] {auto-filled subject}" + is FeedbackEmailType.Visa.DirectUserRequest -> "[Visa] {auto-filled subject}" + is FeedbackEmailType.Visa.Dispute -> "[Visa] [DISPUTE] {auto-filled subject}" } } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt index aeaa917e5f..e1ec372cc9 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt @@ -3,4 +3,5 @@ package com.tangem.domain.feedback.utils internal const val GMAIL_MAX_FILE_SIZE = 24_900_000 // ≈ 25 MB internal const val START2COIN_SUPPORT_EMAIL = "cardsupport@start2coin.com" -internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com" \ No newline at end of file +internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com" +internal const val TANGEM_VISA_SUPPORT_EMAIL = "pay@tangem.com" \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt index d15b9f68bd..ba14ca68b2 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -10,6 +10,7 @@ import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchain.common.address.EstimationFeeAddressFactory import com.tangem.blockchain.common.pagination.Page +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.trustlines.AssetRequirementsManager @@ -488,9 +489,20 @@ class DefaultWalletManagersFacade( val destination = estimationFeeAddressFactory.makeAddress(blockchain) + val callData = if (amount.type is AmountType.Token) { + SmartContractCallDataProviderFactory.getTokenTransferCallData( + destinationAddress = destination, + amount = amount, + blockchain = blockchain, + ) + } else { + null + } + (walletManager as? TransactionSender)?.estimateFee( amount = amount, destination = destination, + callData = callData, ) } @@ -697,6 +709,11 @@ class DefaultWalletManagersFacade( return walletManager.getSalePrice(collectionIdentifier, assetIdentifier) } + override suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? { + val blockchain = Blockchain.fromId(network.id.value) + return blockchain.getNFTExploreUrl(assetIdentifier) + } + override suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean { val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) val initializableAccountWalletManger = walletManager as? InitializableAccount ?: return true diff --git a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt index b40c497186..45a93fff76 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -273,6 +273,8 @@ interface WalletManagersFacade { assetIdentifier: NFTAsset.Identifier, ): NFTAsset.SalePrice? + suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? + /** * If wallet manager implements [InitializableAccount] then returns [InitializableAccount.isAccountInitialized] * value. Otherwise always return true diff --git a/domain/manage-tokens/build.gradle.kts b/domain/manage-tokens/build.gradle.kts index ef5facece8..cff1f6e9aa 100644 --- a/domain/manage-tokens/build.gradle.kts +++ b/domain/manage-tokens/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { api(projects.domain.core) api(projects.domain.manageTokens.models) api(projects.domain.networks) + api(projects.domain.quotes) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) implementation(projects.domain.staking) diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt index 9f78e90712..8379c39929 100644 --- a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/SaveManagedTokensUseCase.kt @@ -6,6 +6,7 @@ import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.managetokens.model.ManagedCryptoCurrency import com.tangem.domain.managetokens.repository.CustomTokensRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency @@ -26,6 +27,7 @@ class SaveManagedTokensUseCase( private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -124,10 +126,19 @@ class SaveManagedTokensUseCase( } private suspend fun refreshUpdatedQuotes(addedCurrencies: List) { - quotesRepository.fetchQuotes( - currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, - refresh = true, - ) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + appCurrencyId = null, + ), + ) + } else { + quotesRepository.fetchQuotes( + currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + refresh = true, + ) + } } /** diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index 6ba3a16199..7d820cb19a 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { api(projects.domain.models) api(projects.domain.networks) api(projects.domain.staking) + api(projects.domain.quotes) api(projects.domain.wallets) api(projects.domain.wallets.models) diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt index dcd308db12..080a102047 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetCurrencyQuotesUseCase.kt @@ -3,6 +3,9 @@ package com.tangem.domain.markets import arrow.core.None import arrow.core.Option import arrow.core.toOption +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteSupplier +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.repository.QuotesRepository @@ -11,9 +14,10 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -@Suppress("UnusedPrivateMember") class GetCurrencyQuotesUseCase( private val quotesRepository: QuotesRepository, + private val singleQuoteSupplier: SingleQuoteSupplier, + private val tokensFeatureToggles: TokensFeatureToggles, ) { // TODO apply interval parameter [REDACTED_TASK_KEY] operator fun invoke( @@ -23,9 +27,18 @@ class GetCurrencyQuotesUseCase( ): Flow> { val rawId = currencyID.rawCurrencyId ?: return flowOf(None) - return quotesRepository.getQuotesUpdatesLegacy( - currenciesIds = setOf(rawId), - refresh = refresh, - ).map { it.filterIsInstance().firstOrNull().toOption() }.catch { emit(None) } + return if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + singleQuoteSupplier( + params = SingleQuoteProducer.Params(rawCurrencyId = rawId), + ) + .map { (it as? Quote.Value).toOption() } + } else { + quotesRepository.getQuotesUpdatesLegacy( + currenciesIds = setOf(rawId), + refresh = refresh, + ) + .map { it.filterIsInstance().firstOrNull().toOption() } + } + .catch { emit(None) } } } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt index 4d086677b5..06b6a81591 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/SaveMarketTokensUseCase.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.card.repository.DerivationsRepository import com.tangem.domain.markets.repositories.MarketsTokenRepository import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency @@ -31,6 +32,7 @@ class SaveMarketTokensUseCase( private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -106,9 +108,18 @@ class SaveMarketTokensUseCase( } private suspend fun refreshUpdatedQuotes(addedCurrencies: List) { - quotesRepository.fetchQuotes( - currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, - refresh = true, - ) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + appCurrencyId = null, + ), + ) + } else { + quotesRepository.fetchQuotes( + currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + refresh = true, + ) + } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt new file mode 100644 index 0000000000..777d3355d8 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.models + +data class ArtworkModel( + val verifiedArtwork: ByteArray? = null, + val defaultUrl: String, +) \ No newline at end of file diff --git a/domain/nft/build.gradle.kts b/domain/nft/build.gradle.kts index b5ce316d58..518f509278 100644 --- a/domain/nft/build.gradle.kts +++ b/domain/nft/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(deps.arrow.core) implementation(deps.kotlin.coroutines) + implementation(projects.core.analytics.models) implementation(projects.core.utils) implementation(projects.domain.core) diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt index 3e2558f928..2c75ead2f4 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt @@ -55,6 +55,14 @@ data class NFTAsset( override val stringValue: String = tokenAddress } + @Serializable + data class Solana( + val tokenAddress: String, + val cnft: Boolean, + ) : Identifier() { + override val stringValue: String = tokenAddress + } + @Serializable data object Unknown : Identifier() { override val stringValue: String = "" diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt index d75d39c6de..eee0569d01 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt @@ -40,6 +40,9 @@ data class NFTCollection( @Serializable data class TON(val contractAddress: String?) : Identifier() + @Serializable + data class Solana(val collection: String?) : Identifier() + @Serializable data object Unknown : Identifier() } diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTExploreUrlUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTExploreUrlUseCase.kt new file mode 100644 index 0000000000..d6794fd7fe --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTExploreUrlUseCase.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.nft + +import arrow.core.raise.catch +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.model.Network + +class GetNFTExploreUrlUseCase( + private val nftRepository: NFTRepository, +) { + + suspend operator fun invoke(network: Network, assetIdentifier: NFTAsset.Identifier): String? = catch( + block = { + nftRepository.getNFTExploreUrl( + network = network, + assetIdentifier = assetIdentifier, + ) + }, + catch = { null }, + ) +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt new file mode 100644 index 0000000000..dbb2139d18 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/analytics/NFTAnalyticsEvent.kt @@ -0,0 +1,60 @@ +package com.tangem.domain.nft.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN +import com.tangem.core.analytics.models.AnalyticsParam.Key.COUNT +import com.tangem.core.analytics.models.AnalyticsParam.Key.STATE + +sealed class NFTAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent( + category = "NFT", + event = event, + params = params, +) { + + data class NFTListScreenOpened( + val state: State, + ) : NFTAnalyticsEvent( + event = "NFT List Screen Opened", + params = buildMap { + put(STATE, state.value) + if (state is State.Full) { + put(COUNT, state.count.toString()) + } + }, + ) { + sealed class State(val value: String) { + data object Empty : State("Empty") + data class Full(val count: Int) : State("Full") + } + } + + object Receive { + data object ScreenOpened : NFTAnalyticsEvent(event = "Receive NFT Screen Opened") + + data class BlockchainChosen( + private val blockchain: String, + ) : NFTAnalyticsEvent(event = "Blockchain Chosen", params = mapOf(BLOCKCHAIN to blockchain)) + + data class CopyAddress( + private val blockchain: String, + ) : NFTAnalyticsEvent(event = "Button - Copy Address", params = mapOf(BLOCKCHAIN to blockchain)) + + data class ShareAddress( + private val blockchain: String, + ) : NFTAnalyticsEvent(event = "Button - Share Address", params = mapOf(BLOCKCHAIN to blockchain)) + } + + object Details { + data class ScreenOpened( + private val blockchain: String, + ) : NFTAnalyticsEvent(event = "NFT Details Screen Opened", params = mapOf(BLOCKCHAIN to blockchain)) + + data object ButtonReadMore : NFTAnalyticsEvent(event = "Button - Read More") + data object ButtonSeeAll : NFTAnalyticsEvent(event = "Button - See All") + data object ButtonExplore : NFTAnalyticsEvent(event = "Button - Explore") + data object ButtonSend : NFTAnalyticsEvent(event = "Button - Send") + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt index 5d811e9a39..36ca075957 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt @@ -1,5 +1,6 @@ package com.tangem.domain.nft.repository +import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.nft.models.NFTCollections import com.tangem.domain.tokens.model.Network @@ -14,4 +15,6 @@ interface NFTRepository { suspend fun refreshAssets(userWalletId: UserWalletId, network: Network, collectionId: NFTCollection.Identifier) suspend fun isNFTSupported(network: Network): Boolean + + suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? } \ No newline at end of file diff --git a/domain/quotes/.gitignore b/domain/quotes/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/quotes/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/quotes/build.gradle.kts b/domain/quotes/build.gradle.kts new file mode 100644 index 0000000000..8f7c2b07d7 --- /dev/null +++ b/domain/quotes/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + api(projects.domain.core) + api(projects.domain.tokens.models) +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt new file mode 100644 index 0000000000..347104dbfb --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/QuotesRepositoryV2.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.quotes + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote + +/** + * Quotes repository + * +[REDACTED_AUTHOR] + */ +interface QuotesRepositoryV2 { + + /** Get quotes by [currenciesIds] synchronously or null */ + suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set): Set? +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt new file mode 100644 index 0000000000..4319ae8399 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.quotes.multi + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.tokens.model.CryptoCurrency + +/** + * Fetcher of quotes + * +[REDACTED_AUTHOR] + */ +interface MultiQuoteFetcher : FlowFetcher { + + /** + * Params + * + * @property currenciesIds identifiers of currencies + * @property appCurrencyId app currency id, if null then selected app currency will be used + */ + data class Params( + val currenciesIds: Set, + val appCurrencyId: String?, + ) +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteUpdater.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteUpdater.kt new file mode 100644 index 0000000000..ee490bac35 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteUpdater.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.quotes.multi + +/** + * Updater of quotes + * +[REDACTED_AUTHOR] + */ +interface MultiQuoteUpdater { + + /** Subscribe */ + fun subscribe() + + /** Unsubscribe */ + fun unsubscribe() +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt new file mode 100644 index 0000000000..7185ea9577 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.quotes.single + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote + +/** + * Producer of quote [CryptoCurrency.RawID] + * +[REDACTED_AUTHOR] + */ +interface SingleQuoteProducer : FlowProducer { + + data class Params(val rawCurrencyId: CryptoCurrency.RawID) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt new file mode 100644 index 0000000000..e65dbb492d --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.quotes.single + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.tokens.model.Quote + +/** + * Supplier of quote [SingleQuoteProducer.Params] + * + * @property factory factory for creating [SingleQuoteProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class SingleQuoteSupplier( + override val factory: SingleQuoteProducer.Factory, + override val keyCreator: (SingleQuoteProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt index ce016aee04..74d271b455 100644 --- a/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt +++ b/domain/staking/models/src/main/kotlin/com/tangem/domain/staking/model/stakekit/YieldBalance.kt @@ -9,21 +9,32 @@ sealed class YieldBalance { abstract val integrationId: String? abstract val address: String? + abstract val source: StatusSource + + fun copySealed(source: StatusSource): YieldBalance { + return when (this) { + is Data -> copy(source = source) + is Empty -> copy(source = source) + is Error -> this + } + } data class Data( override val integrationId: String?, override val address: String, + override val source: StatusSource, val balance: YieldBalanceItem, - val source: StatusSource, ) : YieldBalance() data class Empty( override val integrationId: String?, override val address: String, - val source: StatusSource, + override val source: StatusSource, ) : YieldBalance() - data class Error(override val integrationId: String?, override val address: String?) : YieldBalance() + data class Error(override val integrationId: String?, override val address: String?) : YieldBalance() { + override val source: StatusSource = StatusSource.ACTUAL + } } data class YieldBalanceItem( diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt index 2d48d88f25..40a8c174a9 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt @@ -1,10 +1,7 @@ package com.tangem.domain.staking import arrow.core.Either -import com.tangem.domain.staking.model.stakekit.BalanceItem -import com.tangem.domain.staking.model.stakekit.BalanceType -import com.tangem.domain.staking.model.stakekit.StakingError -import com.tangem.domain.staking.model.stakekit.Token +import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionType import com.tangem.domain.staking.repositories.StakingErrorResolver @@ -51,6 +48,9 @@ class InvalidatePendingTransactionsUseCase( } StakingActionType.WITHDRAW -> { modifyBalancesByStatus(balances, action, BalanceType.UNSTAKED) + if (token.network == NetworkType.TON) { + modifyBalancesByStatus(balances, action, BalanceType.PREPARING) + } } StakingActionType.UNLOCK_LOCKED -> { modifyBalancesByStatus(balances, action, BalanceType.LOCKED) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceProducer.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceProducer.kt new file mode 100644 index 0000000000..eaaff5ad56 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.staking.multi + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Producer of all yield balances for selected wallet [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface MultiYieldBalanceProducer : FlowProducer> { + + data class Params(val userWalletId: UserWalletId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceSupplier.kt new file mode 100644 index 0000000000..4c6cbed1f3 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceSupplier.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.staking.multi + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.staking.model.stakekit.YieldBalance + +/** + * Supplier of all yield balances for selected wallet [MultiYieldBalanceProducer.Params] + * + * @property factory factory for creating [MultiYieldBalanceProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class MultiYieldBalanceSupplier( + override val factory: FlowProducer.Factory, + override val keyCreator: (MultiYieldBalanceProducer.Params) -> String, +) : FlowCachingSupplier>() \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 6ba910ceae..f24fb5baf6 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(projects.domain.promo.models) implementation(projects.domain.promo) implementation(projects.domain.networks) + implementation(projects.domain.quotes) /** Project - Api */ implementation(projects.features.send.api) diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt index ebfb2e7525..6e3d9532f0 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt @@ -7,6 +7,13 @@ sealed interface Quote { val rawCurrencyId: CryptoCurrency.RawID + fun copySealed(source: StatusSource): Quote { + return when (this) { + is Empty -> this + is Value -> copy(source = source) + } + } + /** * Represents unknown financial information for a specific cryptocurrency. * diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt index 56fc636d4f..e2354e1813 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/AddCryptoCurrenciesUseCase.kt @@ -5,6 +5,7 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network @@ -12,6 +13,9 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope /** * A use case for adding multiple cryptocurrencies to a user's wallet. @@ -20,12 +24,14 @@ import com.tangem.domain.wallets.models.UserWalletId * network statuses, particularly after the addition of new tokens. */ // TODO: Add tests +@Suppress("LongParameterList") class AddCryptoCurrenciesUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -68,9 +74,14 @@ class AddCryptoCurrenciesUseCase( val currencyToAdd = currency.takeUnless(existingCurrencies::contains) ?: return@either addCurrencies(userWalletId, currencyToAdd) - refreshUpdatedNetworks(userWalletId, currencyToAdd, existingCurrencies) - refreshUpdatedYieldBalances(userWalletId, currencyToAdd) - refreshUpdatedQuotes(currencyToAdd) + + coroutineScope { + awaitAll( + async { refreshUpdatedNetworks(userWalletId, currencyToAdd, existingCurrencies) }, + async { refreshUpdatedYieldBalances(userWalletId, currencyToAdd) }, + async { refreshUpdatedQuotes(currencyToAdd) }, + ) + } } suspend operator fun invoke( @@ -94,9 +105,15 @@ class AddCryptoCurrenciesUseCase( } val tokenToAdd = createTokenCurrency(userWalletId, contractAddress, networkId) addCurrencies(userWalletId, tokenToAdd) - refreshUpdatedNetworks(userWalletId, tokenToAdd, existingCurrencies) - refreshUpdatedYieldBalances(userWalletId, tokenToAdd) - refreshUpdatedQuotes(tokenToAdd) + + coroutineScope { + awaitAll( + async { refreshUpdatedNetworks(userWalletId, tokenToAdd, existingCurrencies) }, + async { refreshUpdatedYieldBalances(userWalletId, tokenToAdd) }, + async { refreshUpdatedQuotes(tokenToAdd) }, + ) + } + tokenToAdd } @@ -150,10 +167,19 @@ class AddCryptoCurrenciesUseCase( } private suspend fun refreshUpdatedQuotes(currencyToAdd: CryptoCurrency) { - quotesRepository.fetchQuotes( - currenciesIds = setOfNotNull(currencyToAdd.id.rawCurrencyId), - refresh = true, - ) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = setOfNotNull(currencyToAdd.id.rawCurrencyId), + appCurrencyId = null, + ), + ) + } else { + quotesRepository.fetchQuotes( + currenciesIds = setOfNotNull(currencyToAdd.id.rawCurrencyId), + refresh = true, + ) + } } private suspend fun Raise.createTokenCurrency( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt index 2098e11d7b..20e19dfaf0 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCardTokenListUseCase.kt @@ -5,6 +5,7 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrency @@ -17,12 +18,14 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +@Suppress("LongParameterList") class FetchCardTokenListUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -90,10 +93,16 @@ class FetchCardTokenListUseCase( } private suspend fun fetchQuotes(currenciesIds: Set, refresh: Boolean) { - catch( - block = { quotesRepository.getQuotesSync(currenciesIds, refresh) }, - catch = { /* Ignore error */ }, - ) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null), + ) + } else { + catch( + block = { quotesRepository.getQuotesSync(currenciesIds, refresh) }, + catch = { /* Ignore error */ }, + ) + } } private suspend fun fetchYieldBalances( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt index fa725b8c75..a38ae046d5 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchCurrencyStatusUseCase.kt @@ -5,6 +5,7 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrency @@ -27,12 +28,14 @@ import kotlinx.coroutines.coroutineScope * @param quotesRepository The repository for retrieving cryptocurrency quotes. */ // TODO: Add tests +@Suppress("LongParameterList") class FetchCurrencyStatusUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -134,10 +137,19 @@ class FetchCurrencyStatusUseCase( } private suspend fun Raise.fetchQuote(currencyId: CryptoCurrency.ID, refresh: Boolean) { - catch( - block = { quotesRepository.getQuotesSync(setOfNotNull(currencyId.rawCurrencyId), refresh) }, - ) { - raise(CurrencyStatusError.DataError(it)) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = setOfNotNull(currencyId.rawCurrencyId), + appCurrencyId = null, + ), + ) + } else { + catch( + block = { quotesRepository.getQuotesSync(setOfNotNull(currencyId.rawCurrencyId), refresh) }, + ) { + raise(CurrencyStatusError.DataError(it)) + } } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt index 5344fecfd0..4602529c72 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/FetchTokenListUseCase.kt @@ -7,6 +7,7 @@ import arrow.core.raise.either import arrow.core.raise.ensureNotNull import arrow.core.toNonEmptyListOrNull import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrency @@ -29,12 +30,14 @@ import kotlinx.coroutines.coroutineScope * @param stakingRepository The repository for retrieving staking-related data. */ // TODO: Add tests +@Suppress("LongParameterList") class FetchTokenListUseCase( private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -125,13 +128,22 @@ class FetchTokenListUseCase( } private suspend fun fetchQuotes(currenciesIds: Set, refresh: Boolean) { - catch( - block = { - val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() - quotesRepository.getQuotesSync(rawIds, refresh) - }, - ) { - /* Ignore error */ + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( + currenciesIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet(), + appCurrencyId = null, + ), + ) + } else { + catch( + block = { + val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() + quotesRepository.getQuotesSync(rawIds, refresh) + }, + ) { + /* Ignore error */ + } } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt new file mode 100644 index 0000000000..72b4d94cc4 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.tokens + +import arrow.core.Either +import com.tangem.domain.tokens.error.CurrencyStatusError +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.models.UserWalletId + +class GetCryptoCurrenciesUseCase( + private val currenciesRepository: CurrenciesRepository, +) { + + /** + * Retrieves the list of cryptocurrencies within a multi-currency wallet. + * + * @param userWalletId The unique identifier of the user wallet. + * + * @return An [Either] representing success (Right) or an error (Left) in fetching the status. + */ + suspend operator fun invoke(userWalletId: UserWalletId): Either> { + return Either.catch { + currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) + }.mapLeft(CurrencyStatusError::DataError) + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt index 1688660d10..9ebfe0bced 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/RefreshMultiCurrencyWalletQuotesUseCase.kt @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.getOrElse import arrow.core.raise.catch import arrow.core.raise.either +import com.tangem.domain.quotes.multi.MultiQuoteFetcher import com.tangem.domain.tokens.error.QuotesError import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -16,6 +17,8 @@ import kotlinx.coroutines.coroutineScope class RefreshMultiCurrencyWalletQuotesUseCase( private val quotesRepository: QuotesRepository, private val currenciesRepository: CurrenciesRepository, + private val multiQuoteFetcher: MultiQuoteFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either { @@ -45,14 +48,23 @@ class RefreshMultiCurrencyWalletQuotesUseCase( } private suspend fun fetchQuotes(currenciesIds: Set) { - catch( - block = { - quotesRepository.fetchQuotes( + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params( currenciesIds = currenciesIds.mapNotNullTo(hashSetOf(), CryptoCurrency.ID::rawCurrencyId), - refresh = true, - ) - }, - catch = { /* Ignore error */ }, - ) + appCurrencyId = null, + ), + ) + } else { + catch( + block = { + quotesRepository.fetchQuotes( + currenciesIds = currenciesIds.mapNotNullTo(hashSetOf(), CryptoCurrency.ID::rawCurrencyId), + refresh = true, + ) + }, + catch = { /* Ignore error */ }, + ) + } } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt index b48aaa3c4d..be40961b10 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt @@ -8,4 +8,8 @@ package com.tangem.domain.tokens interface TokensFeatureToggles { val isNetworksLoadingRefactoringEnabled: Boolean + + val isQuotesLoadingRefactoringEnabled: Boolean + + val isStakingLoadingRefactoringEnabled: Boolean } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt index ac008a8ac8..14c18382e1 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/BaseCurrencyStatusOperations.kt @@ -10,6 +10,9 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusProducer import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteSupplier import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.repositories.StakingRepository @@ -37,10 +40,12 @@ import kotlinx.coroutines.flow.* abstract class BaseCurrencyStatusOperations( private val currenciesRepository: CurrenciesRepository, private val quotesRepository: QuotesRepository, + private val quotesRepositoryV2: QuotesRepositoryV2, private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + private val singleQuoteSupplier: SingleQuoteSupplier, private val tokensFeatureToggles: TokensFeatureToggles, ) { @@ -171,7 +176,16 @@ abstract class BaseCurrencyStatusOperations( } else { currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId, cryptoCurrencyId) } - val quote = cryptoCurrencyId.rawCurrencyId?.let { quotesRepository.getQuoteSync(it) }?.right() + + val quote = cryptoCurrencyId.rawCurrencyId?.let { rawId -> + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + singleQuoteSupplier(params = SingleQuoteProducer.Params(rawCurrencyId = rawId)) + .firstOrNull() + } else { + quotesRepository.getQuoteSync(rawId) + } + } + ?.right() ?: Error.EmptyQuotes.left() val networkStatuses = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { @@ -237,7 +251,12 @@ abstract class BaseCurrencyStatusOperations( ?: return emptyList().right() val (networks, currenciesIds) = getIds(nonEmptyCurrencies) val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() - val quotes = quotesRepository.getQuotesSync(rawIds, false).right() + + val quotes = if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = rawIds)?.right() + } else { + quotesRepository.getQuotesSync(rawIds, false).right() + } val networkStatuses = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { multiNetworkStatusSupplier( @@ -268,13 +287,23 @@ abstract class BaseCurrencyStatusOperations( block = { currenciesRepository.getSingleCurrencyWalletPrimaryCurrency(userWalletId) }, catch = { raise(Error.DataError(it)) }, ) - val quotes = catch( - block = { - currency.id.rawCurrencyId?.let { quotesRepository.getQuoteSync(it) } - ?.right() ?: Error.EmptyQuotes.left() - }, - catch = { Error.DataError(it).left() }, - ) + + val quotes = if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + currency.id.rawCurrencyId?.let { + singleQuoteSupplier(params = SingleQuoteProducer.Params(rawCurrencyId = it)) + .firstOrNull() + } + ?.right() + ?: Error.EmptyQuotes.left() + } else { + catch( + block = { + currency.id.rawCurrencyId?.let { quotesRepository.getQuoteSync(it) } + ?.right() ?: Error.EmptyQuotes.left() + }, + catch = { Error.DataError(it).left() }, + ) + } val networkStatus = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { singleNetworkStatusSupplier( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt index 4fea4f5fbd..919ee39358 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CachedCurrenciesStatusesOperations.kt @@ -14,6 +14,10 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.quotes.QuotesRepositoryV2 +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.quotes.single.SingleQuoteSupplier import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.repositories.StakingRepository @@ -34,20 +38,25 @@ import kotlinx.coroutines.flow.* class CachedCurrenciesStatusesOperations( private val currenciesRepository: CurrenciesRepository, private val quotesRepository: QuotesRepository, + quotesRepositoryV2: QuotesRepositoryV2, private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, multiNetworkStatusSupplier: MultiNetworkStatusSupplier, private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiQuoteFetcher: MultiQuoteFetcher, + private val singleQuoteSupplier: SingleQuoteSupplier, private val tokensFeatureToggles: TokensFeatureToggles, ) : BaseCurrenciesStatusesOperations, BaseCurrencyStatusOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, + quotesRepositoryV2 = quotesRepositoryV2, networksRepository = networksRepository, stakingRepository = stakingRepository, multiNetworkStatusSupplier = multiNetworkStatusSupplier, singleNetworkStatusSupplier = singleNetworkStatusSupplier, + singleQuoteSupplier = singleQuoteSupplier, tokensFeatureToggles = tokensFeatureToggles, ) { @@ -178,7 +187,14 @@ class CachedCurrenciesStatusesOperations( }, async { val rawCurrenciesIds = currenciesIds.mapNotNullTo(mutableSetOf()) { it.rawCurrencyId } - quotesRepository.fetchQuotes(rawCurrenciesIds) + + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + multiQuoteFetcher( + params = MultiQuoteFetcher.Params(currenciesIds = rawCurrenciesIds, appCurrencyId = null), + ) + } else { + quotesRepository.fetchQuotes(rawCurrenciesIds) + } }, async { stakingRepository.fetchMultiYieldBalance(userWalletId, currencies) }, ) @@ -249,27 +265,44 @@ class CachedCurrenciesStatusesOperations( } private fun getQuotes(tokensIds: NonEmptySet): Flow>> { - return quotesRepository.getQuotesUpdates(tokensIds.mapNotNull { it.rawCurrencyId }.toSet()) - .map, Either>> { it.right() } - .retryWhen { cause, _ -> - emit(TokenListError.DataError(cause).left()) - // adding delay before retry to avoid spam when flow restarted - delay(RETRY_DELAY) - true - } - .distinctUntilChanged() + return if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + getQuotesUpdates( + rawCurrencyIds = tokensIds.mapNotNullTo( + destination = hashSetOf(), + transform = CryptoCurrency.ID::rawCurrencyId, + ), + ) + } else { + quotesRepository.getQuotesUpdates(tokensIds.mapNotNull { it.rawCurrencyId }.toSet()) + .map, Either>> { it.right() } + .retryWhen { cause, _ -> + emit(TokenListError.DataError(cause).left()) + // adding delay before retry to avoid spam when flow restarted + delay(RETRY_DELAY) + true + } + .distinctUntilChanged() + } } override fun getQuotes(id: CryptoCurrency.RawID): Flow>> { - return quotesRepository.getQuotesUpdates(setOf(id)) - .map, Either>> { it.right() } - .retryWhen { cause, _ -> - emit(Error.DataError(cause).left()) - // adding delay before retry to avoid spam when flow restarted - delay(RETRY_DELAY) - true - } - .distinctUntilChanged() + return if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + singleQuoteSupplier( + params = SingleQuoteProducer.Params(rawCurrencyId = id), + ) + .map>> { setOf(it).right() } + .distinctUntilChanged() + } else { + quotesRepository.getQuotesUpdates(setOf(id)) + .map, Either>> { it.right() } + .retryWhen { cause, _ -> + emit(Error.DataError(cause).left()) + // adding delay before retry to avoid spam when flow restarted + delay(RETRY_DELAY) + true + } + .distinctUntilChanged() + } } override fun getNetworksStatuses( @@ -363,6 +396,33 @@ class CachedCurrenciesStatusesOperations( .distinctUntilChanged() } + // temporary code because token list is built using networks list + private fun getQuotesUpdates(rawCurrencyIds: Set): EitherFlow> { + return channelFlow { + val state = MutableStateFlow(emptySet()) + + rawCurrencyIds.onEach { + launch { + singleQuoteSupplier( + params = SingleQuoteProducer.Params(rawCurrencyId = it), + ) + .onEach { quote -> + state.update { loadedStatuses -> + loadedStatuses.addOrReplace(quote) { it.rawCurrencyId == quote.rawCurrencyId } + } + } + .launchIn(scope = this) + } + } + + state + .onEach(::send) + .launchIn(scope = this) + } + .map, Either>> { it.right() } + .distinctUntilChanged() + } + private fun isFetchingStarted(userWalletId: UserWalletId): Boolean { return fetchingState.value[userWalletId]?.let { it.isStarted() || it.isFinished() } ?: false } diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt index 3e5e99c917..841fbf11ab 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/GetPrimaryCurrencyStatusUpdatesUseCaseTest.kt @@ -180,10 +180,15 @@ internal class GetPrimaryCurrencyStatusUpdatesUseCaseTest { tokensFeatureToggles = object : TokensFeatureToggles { override val isNetworksLoadingRefactoringEnabled: Boolean = false + override val isQuotesLoadingRefactoringEnabled: Boolean = false + override val isStakingLoadingRefactoringEnabled: Boolean = false }, singleNetworkStatusSupplier = mockk(), multiNetworkStatusFetcher = mockk(), multiNetworkStatusSupplier = mockk(), + multiQuoteFetcher = mockk(), + singleQuoteSupplier = mockk(), + quotesRepositoryV2 = mockk(), ), dispatchers = dispatchers, ) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt index 61ac813b52..9ef573a666 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/TransactionRepository.kt @@ -1,12 +1,12 @@ package com.tangem.domain.transaction import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionSendResult import com.tangem.blockchain.common.transaction.TransactionsSendResult import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network -import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.wallets.models.UserWalletId import java.math.BigDecimal import java.math.BigInteger @@ -22,7 +22,16 @@ interface TransactionRepository { userWalletId: UserWalletId, network: Network, txExtras: TransactionExtras?, - hash: String?, + ): TransactionData.Uncompiled + + @Suppress("LongParameterList") + suspend fun createTransferTransaction( + amount: Amount, + fee: Fee, + memo: String?, + destination: String, + userWalletId: UserWalletId, + network: Network, ): TransactionData.Uncompiled @Suppress("LongParameterList") @@ -34,7 +43,6 @@ interface TransactionRepository { spenderAddress: String, userWalletId: UserWalletId, network: Network, - hash: String?, ): TransactionData.Uncompiled @Suppress("LongParameterList") @@ -45,9 +53,6 @@ interface TransactionRepository { destination: String, userWalletId: UserWalletId, network: Network, - isSwap: Boolean = false, - txExtras: TransactionExtras?, - hash: String? = null, ): Result suspend fun sendTransaction( @@ -66,9 +71,8 @@ interface TransactionRepository { ): com.tangem.blockchain.extensions.Result fun createTransactionDataExtras( - data: String, + callData: SmartContractCallData, network: Network, - transactionType: TransactionType, nonce: BigInteger?, gasLimit: BigInteger?, ): TransactionExtras @@ -78,4 +82,11 @@ interface TransactionRepository { cryptoCurrency: CryptoCurrency.Token, spenderAddress: String, ): BigDecimal + + suspend fun prepareForSend( + transactionData: TransactionData, + signer: TransactionSigner, + userWalletId: UserWalletId, + network: Network, + ): Result } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt index 65d69a9d0a..d3b136ff3e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateApprovalTransactionUseCase.kt @@ -8,6 +8,9 @@ import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWalletId import java.math.BigDecimal +/** + * Use case to create and get approval transaction + */ class CreateApprovalTransactionUseCase( private val transactionRepository: TransactionRepository, ) { @@ -20,7 +23,6 @@ class CreateApprovalTransactionUseCase( fee: Fee, contractAddress: String, spenderAddress: String, - hash: String? = null, ) = Either.catch { transactionRepository.createApprovalTransaction( amount = BigDecimal.ZERO.convertToSdkAmount(cryptoCurrency), @@ -30,7 +32,6 @@ class CreateApprovalTransactionUseCase( userWalletId = userWalletId, network = cryptoCurrency.network, fee = fee, - hash = hash, ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionDataExtrasUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionDataExtrasUseCase.kt index 52d686e403..c63384ba5e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionDataExtrasUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionDataExtrasUseCase.kt @@ -1,30 +1,25 @@ package com.tangem.domain.transaction.usecase import arrow.core.Either +import com.tangem.blockchain.common.smartcontract.CompiledSmartContractCallData +import com.tangem.common.extensions.hexToBytes import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository -import com.tangem.domain.transaction.models.TransactionType import java.math.BigInteger class CreateTransactionDataExtrasUseCase( private val transactionRepository: TransactionRepository, ) { - operator fun invoke( - data: String, - network: Network, - transactionType: TransactionType, - gasLimit: BigInteger? = null, - nonce: BigInteger? = null, - ) = Either.catch { - requireNotNull( - transactionRepository.createTransactionDataExtras( - data = data, - network = network, - transactionType = transactionType, - nonce = nonce, - gasLimit = gasLimit, - ), - ) { "Failed to create transaction" } - } + operator fun invoke(data: String, network: Network, gasLimit: BigInteger? = null, nonce: BigInteger? = null) = + Either.catch { + requireNotNull( + transactionRepository.createTransactionDataExtras( + callData = CompiledSmartContractCallData(data.hexToBytes()), + network = network, + nonce = nonce, + gasLimit = gasLimit, + ), + ) { "Failed to create transaction" } + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt index 87f10ea630..a9d080eb2e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransactionUseCase.kt @@ -8,6 +8,12 @@ import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.wallets.models.UserWalletId +/** + * Use case to create and get transaction + * + * !!!IMPORTANT + * Use when transaction data is already compiled by external service or provider + */ class CreateTransactionUseCase( private val transactionRepository: TransactionRepository, ) { @@ -24,7 +30,6 @@ class CreateTransactionUseCase( userWalletId: UserWalletId, network: Network, txExtras: TransactionExtras? = null, - hash: String? = null, ) = Either.catch { transactionRepository.createTransaction( amount = amount, @@ -34,7 +39,6 @@ class CreateTransactionUseCase( userWalletId = userWalletId, network = network, txExtras = txExtras, - hash = hash, ) } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt new file mode 100644 index 0000000000..64457a93b8 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/CreateTransferTransactionUseCase.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Use case to create and get transfer transaction + * + * !!!IMPORTANT + * Use when transaction data is compiled by us using BlockchainSDK methods + */ +class CreateTransferTransactionUseCase( + private val transactionRepository: TransactionRepository, +) { + + /** + * [REDACTED_TODO_COMMENT] + */ + @Suppress("LongParameterList") + suspend operator fun invoke( + amount: Amount, + fee: Fee, + memo: String?, + destination: String, + userWalletId: UserWalletId, + network: Network, + ) = Either.catch { + transactionRepository.createTransferTransaction( + amount = amount, + fee = fee, + memo = memo, + destination = destination, + userWalletId = userWalletId, + network = network, + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt index 548a43ab6c..c370ac644d 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt @@ -17,6 +17,9 @@ import java.math.BigDecimal /** * Use case to get transaction fee + * + * !!!IMPORTANT!!! + * Use when transaction data is already compiled by external service or provider */ class GetFeeUseCase( private val walletManagersFacade: WalletManagersFacade, diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetTransferFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetTransferFeeUseCase.kt new file mode 100644 index 0000000000..0a1582967d --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetTransferFeeUseCase.kt @@ -0,0 +1,102 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory +import com.tangem.blockchain.extensions.Result +import com.tangem.domain.demo.DemoConfig +import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.mapToFeeError +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet +import java.math.BigDecimal + +/** + * Use case to get transfer transaction fee + * + * !!!IMPORTANT + * Use when transaction data is compiled by us using BlockchainSDK methods + */ +class GetTransferFeeUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val demoConfig: DemoConfig, +) { + suspend operator fun invoke( + amount: BigDecimal, + destination: String, + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + ) = either { + catch( + block = { + val amountData = convertCryptoCurrencyToAmount(cryptoCurrency, amount) + + val result = if (demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) { + demoTransactionSender(userWallet, cryptoCurrency).getFee( + amount = amountData, + destination = destination, + ) + } else { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ) + val smartContractCallData = if (amountData.type is AmountType.Token) { + SmartContractCallDataProviderFactory.getTokenTransferCallData( + amount = amountData, + destinationAddress = destination, + blockchain = Blockchain.fromId(cryptoCurrency.network.id.value), + ) + } else { + null + } + + (walletManager as? TransactionSender)?.getFee( + amount = amountData, + destination = destination, + callData = smartContractCallData, + ) ?: error("Fee is null") + } + + val maybeFee = when (result) { + is Result.Success -> result.data + is Result.Failure -> raise(result.mapToFeeError()) + } + maybeFee + }, + catch = { + raise(GetFeeError.DataError(it)) + }, + ) + } + + private suspend fun demoTransactionSender( + userWallet: UserWallet, + cryptoCurrency: CryptoCurrency, + ): DemoTransactionSender { + return DemoTransactionSender( + walletManagersFacade + .getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network) + ?: error("WalletManager is null"), + ) + } + + private fun convertCryptoCurrencyToAmount(cryptoCurrency: CryptoCurrency, amount: BigDecimal) = Amount( + currencySymbol = cryptoCurrency.symbol, + value = amount, + decimals = cryptoCurrency.decimals, + type = when (cryptoCurrency) { + is CryptoCurrency.Coin -> AmountType.Coin + is CryptoCurrency.Token -> AmountType.Token( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) + }, + ) +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt new file mode 100644 index 0000000000..58d073be30 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrepareForSendUseCase.kt @@ -0,0 +1,38 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.common.TransactionData +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.transaction.TransactionRepository +import com.tangem.domain.wallets.models.UserWallet + +class PrepareForSendUseCase( + private val transactionRepository: TransactionRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, +) { + suspend operator fun invoke( + transactionData: TransactionData, + userWallet: UserWallet, + network: Network, + ): Either { + val card = userWallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + + val signer = cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + ) + return transactionRepository.prepareForSend( + transactionData = transactionData, + userWalletId = userWallet.walletId, + network = network, + signer = signer, + ) + .fold(onSuccess = { it.right() }, onFailure = { it.left() }) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt new file mode 100644 index 0000000000..d5496faf38 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt @@ -0,0 +1,40 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.common.CompletionResult +import com.tangem.common.core.TangemError +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet + +class SignUseCase( + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val walletManagersFacade: WalletManagersFacade, +) { + suspend operator fun invoke( + hash: ByteArray, + userWallet: UserWallet, + network: Network, + ): Either { + val card = userWallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + + val signer = cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), + ) + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network) + ?: error("WalletManager not found") + + return when (val signResult = signer.sign(hash, walletManager.wallet.publicKey)) { + is CompletionResult.Failure -> signResult.error.left() + is CompletionResult.Success -> signResult.data.right() + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateTransactionUseCase.kt index 61b2f26817..11b429be0f 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateTransactionUseCase.kt @@ -21,8 +21,6 @@ class ValidateTransactionUseCase( destination: String, userWalletId: UserWalletId, network: Network, - isSwap: Boolean = false, - hash: String? = null, ): Either { return transactionRepository.validateTransaction( amount = amount, @@ -31,10 +29,6 @@ class ValidateTransactionUseCase( destination = destination, userWalletId = userWalletId, network = network, - isSwap = isSwap, - txExtras = null, - hash = hash, - ) - .fold(onSuccess = { Unit.right() }, onFailure = { it.left() }) + ).fold(onSuccess = { Unit.right() }, onFailure = { it.left() }) } } \ No newline at end of file diff --git a/domain/visa/models/build.gradle.kts b/domain/visa/models/build.gradle.kts index 0aafe4291a..2de3acff50 100644 --- a/domain/visa/models/build.gradle.kts +++ b/domain/visa/models/build.gradle.kts @@ -10,5 +10,6 @@ dependencies { ksp(deps.moshi.kotlin.codegen) implementation(deps.moshi.adapters) implementation(deps.kotlin.serialization) + implementation(deps.jodatime) implementation(projects.core.error) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt index 78bbd9692b..76389945fd 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt @@ -8,6 +8,6 @@ import kotlinx.serialization.Serializable @JsonClass(generateAdapter = true) data class VisaActivationOrderInfo( @Json(name = "orderId") val orderId: String, - @Json(name = "customer_id") val customerId: String, - @Json(name = "customer_wallet_address") val customerWalletAddress: String, + @Json(name = "customerId") val customerId: String, + @Json(name = "customerWalletAddress") val customerWalletAddress: String, ) \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt index 88e4389287..889037bee3 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt @@ -20,9 +20,14 @@ sealed class VisaActivationRemoteState { data object PaymentAccountDeploying : VisaActivationRemoteState() @Serializable - data class WaitingPinCode( + data class AwaitingPinCode( val activationOrderInfo: VisaActivationOrderInfo, - ) : VisaActivationRemoteState() + val status: Status, + ) : VisaActivationRemoteState() { + enum class Status { + WaitingForPinCode, InProgress, WasError + } + } @Serializable data object WaitingForActivationFinishing : VisaActivationRemoteState() @@ -43,6 +48,7 @@ class VisaActivationRemoteState_Json( @Json(name = "type") val type: VisaActivationRemoteState_Type, @Json(name = "requestCardWallet") val requestCardWallet: VisaCardWalletDataToSignRequest? = null, @Json(name = "activationOrderInfo") val activationOrderInfo: VisaActivationOrderInfo? = null, + @Json(name = "awaiting_pin_code_status") val awaitingPinCodeStatus: AwaitingPinCodeStatus_Type? = null, ) @Suppress("ClassNaming") @@ -58,6 +64,12 @@ enum class VisaActivationRemoteState_Type { BlockedForActivation, } +@Suppress("ClassNaming") +@JsonClass(generateAdapter = false) +enum class AwaitingPinCodeStatus_Type { + WaitingForPinCode, InProgress, WasError +} + @Suppress("ClassNaming") class VisaActivationRemoteState_JsonAdapter { @@ -70,7 +82,17 @@ class VisaActivationRemoteState_JsonAdapter { VisaActivationRemoteState.CustomerWalletSignatureRequired(value.activationOrderInfo!!) VisaActivationRemoteState_Type.PaymentAccountDeploying -> VisaActivationRemoteState.PaymentAccountDeploying VisaActivationRemoteState_Type.WaitingPinCode -> - VisaActivationRemoteState.WaitingPinCode(value.activationOrderInfo!!) + VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = value.activationOrderInfo!!, + status = when (value.awaitingPinCodeStatus!!) { + AwaitingPinCodeStatus_Type.WaitingForPinCode -> + VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode + AwaitingPinCodeStatus_Type.InProgress -> + VisaActivationRemoteState.AwaitingPinCode.Status.InProgress + AwaitingPinCodeStatus_Type.WasError -> + VisaActivationRemoteState.AwaitingPinCode.Status.WasError + }, + ) VisaActivationRemoteState_Type.WaitingForActivationFinishing -> VisaActivationRemoteState.WaitingForActivationFinishing VisaActivationRemoteState_Type.Activated -> VisaActivationRemoteState.Activated @@ -93,10 +115,18 @@ class VisaActivationRemoteState_JsonAdapter { ) is VisaActivationRemoteState.PaymentAccountDeploying -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.PaymentAccountDeploying) - is VisaActivationRemoteState.WaitingPinCode -> + is VisaActivationRemoteState.AwaitingPinCode -> VisaActivationRemoteState_Json( VisaActivationRemoteState_Type.WaitingPinCode, activationOrderInfo = value.activationOrderInfo, + awaitingPinCodeStatus = when (value.status) { + VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode -> + AwaitingPinCodeStatus_Type.WaitingForPinCode + VisaActivationRemoteState.AwaitingPinCode.Status.InProgress -> + AwaitingPinCodeStatus_Type.InProgress + VisaActivationRemoteState.AwaitingPinCode.Status.WasError -> + AwaitingPinCodeStatus_Type.WasError + }, ) is VisaActivationRemoteState.WaitingForActivationFinishing -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.WaitingForActivationFinishing) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt similarity index 100% rename from domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt rename to domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt diff --git a/domain/wallet-connect/build.gradle.kts b/domain/wallet-connect/build.gradle.kts index 9b0cbaa2e3..f20c31f3c4 100644 --- a/domain/wallet-connect/build.gradle.kts +++ b/domain/wallet-connect/build.gradle.kts @@ -1,14 +1,23 @@ plugins { - alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) id("configuration") } +android { + namespace = "com.tangem.domain.walletconnect" +} + dependencies { /* Project - Domain */ implementation(projects.domain.core) + implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) implementation(projects.domain.walletConnect.models) /* Other */ implementation(deps.moshi.adapters) + + /* Tangem libraries */ + implementation(tangemDeps.blockchain) } \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt new file mode 100644 index 0000000000..15d22104ba --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthMethod.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.walletconnect.model + +sealed interface WcEthMethod : WcMethod { + + data class MessageSign( + val message: String, + val account: String, + ) : WcEthMethod + + data class SendTransaction( + val transaction: WcEthTransactionParams, + ) : WcEthMethod + + data class SignTransaction( + val transaction: WcEthTransactionParams, + ) : WcEthMethod +} \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt new file mode 100644 index 0000000000..cdde8975d6 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcEthTransactionParams.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.walletconnect.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class WcEthTransactionParams( + @Json(name = "from") + val from: String, + + @Json(name = "to") + val to: String?, + + @Json(name = "data") + val data: String, + + @Json(name = "gas") + val gas: String?, + + @Json(name = "gasPrice") + val gasPrice: String?, + + @Json(name = "value") + val value: String?, + + @Json(name = "nonce") + val nonce: String?, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt index 05c3df4d98..fafbb31a81 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt @@ -1,5 +1,5 @@ package com.tangem.domain.walletconnect.model -interface WcMethod { +sealed interface WcMethod { data object Unsupported : WcMethod } \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcNetwork.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcNetwork.kt deleted file mode 100644 index 1ef5352c31..0000000000 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcNetwork.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.domain.walletconnect.model - -import com.tangem.domain.tokens.model.Network - -sealed interface WcNetwork { - - val name: String - - data class Supported(val network: Network) : WcNetwork { - override val name: String get() = network.name - } - - data class Unknown(override val name: String) : WcNetwork -} \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt index 8f90d64512..5a060eaaba 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt @@ -4,7 +4,7 @@ sealed class WcPairError(override val message: String) : Exception(message) { data object UnsupportedDApp : WcPairError("UnsupportedDApp") data class UnsupportedNetworks( - val chains: Set, + val chains: Set, ) : WcPairError("ApprovalErrorMissingNetworks") data class ExternalApprovalError( diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt index 92501d8bba..cc898e4618 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt @@ -1,9 +1,9 @@ package com.tangem.domain.walletconnect.model import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet data class WcSession( - val userWalletId: UserWalletId, + val wallet: UserWallet, val sdkModel: WcSdkSession, ) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt index 06f4291649..427c4a9d89 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt @@ -1,8 +1,9 @@ package com.tangem.domain.walletconnect.model -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet data class WcSessionApprove( - val walletId: UserWalletId, - val network: List, + val wallet: UserWallet, + val network: List, ) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt index 648163817b..50d299303f 100644 --- a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt @@ -1,19 +1,20 @@ package com.tangem.domain.walletconnect.model +import com.tangem.domain.tokens.model.Network import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet data class WcSessionProposal( val dAppMetaData: WcAppMetaData, - val proposalNetwork: Map, + val proposalNetwork: Map, val securityStatus: Any, ) { data class ProposalNetwork( - val walletId: UserWalletId, - val missingRequired: Set, - val required: Set, - val available: Set, - val notAdded: Set, + val wallet: UserWallet, + val missingRequired: Set, + val required: Set, + val available: Set, + val notAdded: Set, ) } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt index 6c2a996c91..fa207f0263 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt @@ -2,12 +2,12 @@ package com.tangem.domain.walletconnect.repository import arrow.core.Either import com.tangem.domain.walletconnect.model.WcSession -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import kotlinx.coroutines.flow.Flow interface WcSessionsManager { - val sessions: Flow>> - suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) - suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either + val sessions: Flow>> + suspend fun saveSession(session: WcSession) + suspend fun removeSession(session: WcSession): Either suspend fun findSessionByTopic(topic: String): WcSession? } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/request/WcRequestService.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/request/WcRequestService.kt deleted file mode 100644 index 5918accfd9..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/request/WcRequestService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.domain.walletconnect.request - -import com.tangem.domain.walletconnect.model.WcRequest -import kotlinx.coroutines.flow.Flow - -interface WcRequestService { - val requests: Flow> -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt index 0adbf1dff6..138c4a419f 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt @@ -2,16 +2,16 @@ package com.tangem.domain.walletconnect.usecase import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.repository.WcSessionsManager -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.models.UserWallet import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first class WcSessionsUseCase(private val sessionsManager: WcSessionsManager) { - operator fun invoke(): Flow>> { + operator fun invoke(): Flow>> { return sessionsManager.sessions } - suspend fun invokeSync(): Map> { + suspend fun invokeSync(): Map> { return sessionsManager.sessions.first() } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSimpleSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSimpleSignUseCase.kt deleted file mode 100644 index e0bb5239a4..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSimpleSignUseCase.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.domain.walletconnect.usecase - -import arrow.core.Either -import kotlinx.coroutines.flow.Flow - -interface WcSimpleSignUseCase : WcUseCase { - - fun signFlow(initModel: SignModel): Flow> - - fun action(action: MiddleAction) - - fun cancel() - fun sign(toSign: SignModel) - - sealed interface State { - val model: SignModel - - data class PreSign(override val model: SignModel) : State - data class Signing(override val model: SignModel) : State - data class Result( - val result: Either, - override val model: SignModel, - ) : State - } -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt index a2efe639cd..1aff7cea04 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt @@ -1,3 +1,13 @@ package com.tangem.domain.walletconnect.usecase -interface WcUseCase \ No newline at end of file +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest + +interface WcUseCase + +interface WcMethodUseCase : WcUseCase { + val session: WcSession + val rawSdkRequest: WcSdkSessionRequest + val network: Network +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt index a34bd8e9f6..707dc20180 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt @@ -25,6 +25,6 @@ class WcDisconnectUseCase( } suspend fun disconnect(session: WcSession) { - sessionsManager.removeSession(session.userWalletId, session) + sessionsManager.removeSession(session) } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/EthPersonalSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/EthPersonalSignUseCase.kt deleted file mode 100644 index 5e86f84006..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/EthPersonalSignUseCase.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.tangem.domain.walletconnect.usecase.ethereum - -import arrow.core.Either -import com.tangem.domain.walletconnect.model.WcRequest -import com.tangem.domain.walletconnect.respond.WcRespondService -import com.tangem.domain.walletconnect.usecase.WcUseCase -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* - -class EthPersonalSignUseCase( - private val wcRequest: WcRequest, - private val respondService: WcRespondService, -) : WcUseCase { - - private val onCallTerminalAction = Channel() - - fun signFlow(): Flow = flow { - val model = SignModel(wcRequest.method.raw) - emit(State.PreSign(model)) - - when (val action = onCallTerminalAction.receiveAsFlow().first()) { - TerminalAction.Cancel -> { - val result = respondService.rejectRequest(wcRequest.rawSdkRequest) - emit(State.Result(result, model)) - } - is TerminalAction.Sign -> { - emit(State.Signing(action.toSign)) - val signed = signAndPrepareForSend() - val result = - if (signed != null) { - respondService.respond(wcRequest.rawSdkRequest, signed) - } else { - respondService.rejectRequest(wcRequest.rawSdkRequest) - } - emit(State.Result(result, model)) - } - } - } - - @Suppress("FunctionOnlyReturningConstant") // todo(wc) remove later - private suspend fun signAndPrepareForSend(): String? { - return null // todo(wc) - } - - fun sign(toSign: SignModel) { - onCallTerminalAction.trySend(TerminalAction.Sign(toSign)) - } - - fun cancel() { - onCallTerminalAction.trySend(TerminalAction.Cancel) - } - - sealed interface TerminalAction { - data class Sign(val toSign: SignModel) : TerminalAction - data object Cancel : TerminalAction - } - - sealed interface State { - val model: SignModel - - data class PreSign(override val model: SignModel) : State - data class Signing(override val model: SignModel) : State - data class Result( - val result: Either, - override val model: SignModel, - ) : State - } - - data class SignModel( - val raw: List, - ) -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMessageSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMessageSignUseCase.kt new file mode 100644 index 0000000000..0895241c36 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMessageSignUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.walletconnect.usecase.ethereum + +import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase + +interface WcEthMessageSignUseCase : + WcSignUseCase, + WcSignUseCase.SimpleRun { + + data class SignModel( + val humanMsg: String, + val account: String, + val rawMsg: String, + ) +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMethod.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMethod.kt deleted file mode 100644 index cf40981044..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMethod.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.domain.walletconnect.usecase.ethereum - -import com.tangem.domain.walletconnect.model.WcMethod - -sealed interface WcEthMethod : WcMethod { - - data class SignMessage( - val raw: List, - ) : WcEthMethod -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthTransactionUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthTransactionUseCase.kt new file mode 100644 index 0000000000..7df50defdc --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthTransactionUseCase.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.walletconnect.usecase.ethereum + +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase + +interface WcEthSendTransactionUseCase : + WcSignUseCase, + WcSignUseCase.SimpleRun { + + fun updateFee(fee: TransactionFee) +} + +interface WcEthSignTransactionUseCase : + WcSignUseCase, + WcSignUseCase.SimpleRun { + + fun updateFee(fee: TransactionFee) +} + +data class WcEthTransaction( + val isFeeByDap: Boolean, + val fee: TransactionFee, +) \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairState.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairState.kt deleted file mode 100644 index 07edd2b5f8..0000000000 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairState.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.domain.walletconnect.usecase.pair - -import arrow.core.Either -import com.tangem.domain.walletconnect.model.WcPairError -import com.tangem.domain.walletconnect.model.WcSession -import com.tangem.domain.walletconnect.model.WcSessionApprove -import com.tangem.domain.walletconnect.model.WcSessionProposal - -sealed interface WcPairState { - data object Loading : WcPairState - data class Error(val error: WcPairError) : WcPairState - data class Proposal(val dAppSession: WcSessionProposal) : WcPairState - - sealed interface Approving : WcPairState { - val session: WcSessionApprove - - data class Loading(override val session: WcSessionApprove) : Approving - data class Result( - override val session: WcSessionApprove, - val result: Either, - ) : Approving - } -} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt index 2de6663dbd..b926213140 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt @@ -1,6 +1,10 @@ package com.tangem.domain.walletconnect.usecase.pair +import arrow.core.Either +import com.tangem.domain.walletconnect.model.WcPairError +import com.tangem.domain.walletconnect.model.WcSession import com.tangem.domain.walletconnect.model.WcSessionApprove +import com.tangem.domain.walletconnect.model.WcSessionProposal import kotlinx.coroutines.flow.Flow interface WcPairUseCase { @@ -11,4 +15,20 @@ interface WcPairUseCase { fun reject() enum class Source { QR, DEEPLINK, CLIPBOARD, ETC } +} + +sealed interface WcPairState { + data object Loading : WcPairState + data class Error(val error: WcPairError) : WcPairState + data class Proposal(val dAppSession: WcSessionProposal) : WcPairState + + sealed interface Approving : WcPairState { + val session: WcSessionApprove + + data class Loading(override val session: WcSessionApprove) : Approving + data class Result( + override val session: WcSessionApprove, + val result: Either, + ) : Approving + } } \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt new file mode 100644 index 0000000000..f011b82087 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt @@ -0,0 +1,30 @@ +package com.tangem.domain.walletconnect.usecase.sign + +import arrow.core.Either +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import kotlinx.coroutines.flow.Flow + +interface WcSignUseCase : WcMethodUseCase { + + fun cancel() + fun sign() + + interface SimpleRun { + operator fun invoke(): Flow> + } + + interface ArgsRun { + operator fun invoke(args: Args): Flow> + } +} + +data class WcSignState( + val signModel: SignModel, + val domainStep: WcSignStep, +) + +sealed interface WcSignStep { + data object PreSign : WcSignStep + data object Signing : WcSignStep + data class Result(val result: Either) : WcSignStep +} \ No newline at end of file diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt index 95b44fff95..df15cfd819 100644 --- a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt @@ -10,7 +10,6 @@ import com.tangem.domain.models.scan.ScanResponse * * @property name User wallet name * @property walletId User wallet [UserWalletId] - * @property artworkUrl User wallet card artwork URL * @property cardsInWallet List of cards IDs assigned with this user's wallet. The list will be empty if the wallet * has been backed up on another device. * @property isMultiCurrency Indicates whether this user wallet can work with more than one currency @@ -22,8 +21,6 @@ data class UserWallet( val name: String, @Json(name = "walletId") val walletId: UserWalletId, - @Json(name = "artworkUrl") - val artworkUrl: String, @Json(name = "cardsInWallet") val cardsInWallet: Set, @Json(name = "isMultiCurrency") diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt index dfaec8126c..7053d98d7d 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt @@ -5,7 +5,6 @@ import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse 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 @@ -13,7 +12,6 @@ import dagger.assisted.AssistedInject class UserWalletBuilder @AssistedInject constructor( @Assisted private val scanResponse: ScanResponse, private val generateWalletNameUseCase: GenerateWalletNameUseCase, - private val getCardImageUseCase: GetCardImageUseCase, ) { private var backupCardsIds: Set = emptySet() private var hasBackupError: Boolean = false @@ -38,7 +36,7 @@ class UserWalletBuilder @AssistedInject constructor( this.hasBackupError = hasBackupError } - suspend fun build(): UserWallet? { + fun build(): UserWallet? { return with(scanResponse) { UserWalletIdBuilder.scanResponse(scanResponse) .build() @@ -50,7 +48,6 @@ class UserWalletBuilder @AssistedInject constructor( isBackupNotAllowed = card.isBackupNotAllowed, isStartToCoin = cardTypesResolver.isStart2Coin(), ), - artworkUrl = getCardImageUseCase.invoke(card.cardId, card.cardPublicKey), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt index b44e440301..048729f9e9 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt @@ -4,8 +4,12 @@ import com.tangem.common.extensions.toHexString import com.tangem.common.services.Result import com.tangem.domain.common.TwinCardNumber import com.tangem.domain.common.TwinsHelper +import com.tangem.domain.models.ArtworkModel import com.tangem.domain.wallets.models.Artwork +import com.tangem.operations.attestation.ArtworkSize +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles /** * Use case for getting card image url @@ -14,7 +18,11 @@ import com.tangem.operations.attestation.OnlineCardVerifier * [REDACTED_AUTHOR] */ -class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { +class GetCardImageUseCase( + private val verifier: OnlineCardVerifier, + private val cardArtworksProvider: CardArtworksProvider, + private val cardSdkFeatureToggles: CardSdkFeatureToggles, +) { /** * Get card image url @@ -22,14 +30,30 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { * @param cardId card id * @param cardPublicKey card public key */ - suspend operator fun invoke(cardId: String, cardPublicKey: ByteArray): String { + suspend operator fun invoke(cardId: String, cardPublicKey: ByteArray, size: ArtworkSize): ArtworkModel { + return if (cardSdkFeatureToggles.isNewArtworkLoadingEnabled) { + val result = cardArtworksProvider.getArtwork( + cardId = cardId, + cardPublicKey = cardPublicKey, + size = size, + ) + when (result) { + is Result.Failure -> ArtworkModel(null, getFallbackArtworkUrl(cardId)) + is Result.Success -> ArtworkModel(result.data, getFallbackArtworkUrl(cardId)) + } + } else { + ArtworkModel(null, getLegacyArtwork(cardId, cardPublicKey)) + } + } + + private suspend fun getLegacyArtwork(cardId: String, cardPublicKey: ByteArray): String { return when (val result = verifier.getCardInfo(cardId, cardPublicKey)) { is Result.Success -> { val artworkId = result.data.artwork?.id if (artworkId.isNullOrEmpty()) { getFallbackArtworkUrl(cardId) } else { - OnlineCardVerifier.getUrlForArtwork( + CardArtworksProvider.getUrlForArtwork( cardId = cardId, cardPublicKey = cardPublicKey.toHexString(), artworkId = artworkId, @@ -41,8 +65,6 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { } } - fun getDefaultFallbackUrl(): String = Artwork.DEFAULT_IMG_URL - private fun getFallbackArtworkUrl(cardId: String): String { return when { cardId.startsWith(Artwork.SERGIO_CARD_ID) -> Artwork.SERGIO_CARD_URL @@ -50,7 +72,7 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { else -> when (TwinsHelper.getTwinCardNumber(cardId)) { TwinCardNumber.First -> Artwork.TWIN_CARD_1_URL TwinCardNumber.Second -> Artwork.TWIN_CARD_2_URL - else -> getDefaultFallbackUrl() + else -> Artwork.DEFAULT_IMG_URL } } } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index 9a4ec03248..37abe05472 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.core.decompose.navigation.DummyRouter import com.tangem.core.navigation.url.DummyUrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsUM @@ -28,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent { val previewState = DetailsUM( items = previewBlocks, footer = previewFooter, + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, popBack = { /* no-op */ }, ) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt index d9bdebec63..7d8f07f26d 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt @@ -29,7 +29,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("My Wallet"), information = getInformation(cardCount = 1), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, onClick = {}, ), @@ -38,7 +37,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("Old wallet"), information = getInformation(cardCount = 2), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, onClick = {}, ), @@ -47,7 +45,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("Multi Card"), information = getInformation(cardCount = 3), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = false, onClick = {}, ), diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt index 15f0fead96..b7c390e3bd 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt @@ -1,9 +1,11 @@ package com.tangem.features.details.entity +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import kotlinx.collections.immutable.ImmutableList internal data class DetailsUM( val items: ImmutableList, val footer: DetailsFooterUM, + val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig, val popBack: () -> Unit, ) \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt new file mode 100644 index 0000000000..18363c2c30 --- /dev/null +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt @@ -0,0 +1,16 @@ +package com.tangem.features.details.entity + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.details.impl.R + +internal data class SelectEmailFeedbackTypeBS( + val onOptionClick: (Option) -> Unit, +) : TangemBottomSheetConfigContent { + + enum class Option(val text: TextReference) { + General(resourceReference(R.string.common_contact_tangem_support)), + Visa(resourceReference(R.string.common_contact_visa_support)), + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index 4ccf6cdd01..c659204216 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -7,17 +7,22 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.common.TapWorkarounds.isVisa import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.redux.LegacyAction import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsUM +import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -47,6 +52,7 @@ internal class DetailsModel @Inject constructor( private val appStateHolder: ReduxStateHolder, private val getCardInfoUseCase: GetCardInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val getWalletsUseCase: GetWalletsUseCase, override val dispatchers: CoroutineDispatcherProvider, ) : Model() { @@ -84,6 +90,7 @@ internal class DetailsModel @Inject constructor( socials = socialsBuilder.buildAll(), appVersion = getAppVersion(), ), + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, popBack = router::pop, ), ) @@ -99,12 +106,88 @@ internal class DetailsModel @Inject constructor( private fun sendFeedback() { modelScope.launch { + val userWallets = getWalletsUseCase.invokeSync() + val scanResponse = getSelectedWalletSyncUseCase().getOrNull()?.scanResponse ?: error("Selected wallet is null") val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(cardInfo = cardInfo)) + val feedbackType = when { + userWallets.all { it.scanResponse.card.isVisa } -> FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + userWallets.all { it.scanResponse.card.isVisa.not() } -> FeedbackEmailType.DirectUserRequest(cardInfo) + else -> { + showFeedbackEmailTypeOptionBS(cardInfo) + return@launch + } + } + + sendFeedbackEmailUseCase(feedbackType) + } + } + + private fun showFeedbackEmailTypeOptionBS(selectedCardInfo: CardInfo) { + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = + it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + ) + } + }, + content = SelectEmailFeedbackTypeBS( + onOptionClick = { option -> + onEmailFeedbackTypeOptionSelected( + selectedCardInfo = selectedCardInfo, + option = option, + ) + + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = + it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + ) + } + }, + ), + ), + ) + } + } + + private fun onEmailFeedbackTypeOptionSelected( + selectedCardInfo: CardInfo, + option: SelectEmailFeedbackTypeBS.Option, + ) { + modelScope.launch { + val feedbackType = when (option) { + SelectEmailFeedbackTypeBS.Option.General -> { + if (selectedCardInfo.isVisa.not()) { + FeedbackEmailType.DirectUserRequest(selectedCardInfo) + } else { + val scanResponse = getWalletsUseCase.invokeSync() + .firstOrNull { it.scanResponse.card.isVisa.not() }?.scanResponse ?: return@launch + val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch + FeedbackEmailType.DirectUserRequest(cardInfo) + } + } + SelectEmailFeedbackTypeBS.Option.Visa -> { + if (selectedCardInfo.isVisa) { + FeedbackEmailType.Visa.DirectUserRequest(selectedCardInfo) + } else { + val scanResponse = getWalletsUseCase.invokeSync() + .firstOrNull { it.scanResponse.card.isVisa }?.scanResponse ?: return@launch + val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch + FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + } + } + } + + sendFeedbackEmailUseCase(feedbackType) } } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt index 0c2faf9735..cf172f3a90 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt @@ -59,6 +59,8 @@ internal fun DetailsScreen( userWalletListBlockContent = userWalletListBlockContent, ) } + + SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig) } @Composable diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt new file mode 100644 index 0000000000..583b911d76 --- /dev/null +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt @@ -0,0 +1,58 @@ +package com.tangem.features.details.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.inputrow.InputRowChecked +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS +import com.tangem.features.details.impl.R + +@Composable +internal fun SelectFeedbackEmailTypeBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + titleText = resourceReference(R.string.common_choose_action), + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: SelectEmailFeedbackTypeBS) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + SelectEmailFeedbackTypeBS.Option.entries.forEachIndexed { index, type -> + DividerContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = SelectEmailFeedbackTypeBS.Option.entries.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable { content.onOptionClick(type) }, + showDivider = index != SelectEmailFeedbackTypeBS.Option.entries.lastIndex, + ) { + InputRowChecked( + text = type.text, + checked = false, + ) + } + } + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt index eef9deef0d..23cde30f76 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt @@ -18,19 +18,23 @@ import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.lce import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.core.utils.toLce +import com.tangem.domain.models.ArtworkModel import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.TotalFiatBalance import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.details.impl.R +import com.tangem.operations.attestation.ArtworkSize import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class UserWalletsFetcher @Inject constructor( getWalletsUseCase: GetWalletsUseCase, @@ -39,8 +43,11 @@ internal class UserWalletsFetcher @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val router: Router, private val messageSender: UiMessageSender, + private val getCardImageUseCase: GetCardImageUseCase, ) { + private var loadedArtworks: HashMap = hashMapOf() + @OptIn(ExperimentalCoroutinesApi::class) val userWallets: Flow> = getWalletsUseCase().transformLatest { wallets -> val uiModels = UserWalletItemUMConverter(onClick = ::navigateToWalletSettings).convertList(wallets) @@ -52,12 +59,14 @@ internal class UserWalletsFetcher @Inject constructor( flow = getSelectedAppCurrencyUseCase().distinctUntilChanged(), flow2 = getBalanceHidingSettingsUseCase().distinctUntilChanged(), flow3 = getWalletTotalBalanceUseCase(wallets.map(UserWallet::walletId)).distinctUntilChanged(), - ) { maybeAppCurrency, balanceHidingSettings, maybeBalances -> + flow4 = loadArtworks(wallets), + ) { maybeAppCurrency, balanceHidingSettings, maybeBalances, artworks -> val models = createUiModels( wallets = wallets, maybeAppCurrency = maybeAppCurrency, maybeBalances = maybeBalances, balanceHidingSettings = balanceHidingSettings, + artworks = artworks, ).getOrElse( ifLoading = { return@combine }, ifError = { @@ -72,11 +81,27 @@ internal class UserWalletsFetcher @Inject constructor( }.collect() } + private fun loadArtworks(wallets: List): Flow> { + return flow { + emit(hashMapOf()) // emits right away so the transform doesn't wait for the images' loading to finish + wallets.forEach { wallet -> + val artwork = getCardImageUseCase( + cardId = wallet.cardId, + cardPublicKey = wallet.scanResponse.card.cardPublicKey, + size = ArtworkSize.SMALL, + ) + loadedArtworks[wallet.walletId] = artwork + emit(loadedArtworks) + } + } + } + private fun createUiModels( wallets: List, maybeAppCurrency: Either, maybeBalances: Lce>, balanceHidingSettings: BalanceHidingSettings, + artworks: HashMap, ): Lce> = lce { val balances = withError( transform = { Error.UnableToGetBalances }, @@ -99,6 +124,7 @@ internal class UserWalletsFetcher @Inject constructor( appCurrency = appCurrency, balance = balance, isBalanceHidden = balanceHidingSettings.isBalanceHidden, + artwork = artworks[userWallet.walletId], ) .convert(userWallet) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt index 16c72a0b96..4dd21ecb8a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -27,7 +27,6 @@ internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider name = stringReference("Wallet 1"), information = stringReference("3 cards"), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, endIcon = UserWalletItemUM.EndIcon.Arrow, onClick = {}, diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt deleted file mode 100644 index 3417b6a1f2..0000000000 --- a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.features.nft.component - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.wallets.models.UserWalletId - -interface NFTCollectionsComponent : ComposableContentComponent { - - data class Params( - val userWalletId: UserWalletId, - ) - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTReceiveComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTComponent.kt similarity index 68% rename from features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTReceiveComponent.kt rename to features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTComponent.kt index 9184afa340..f2f8d3f9e5 100644 --- a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTReceiveComponent.kt +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTComponent.kt @@ -4,11 +4,11 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.wallets.models.UserWalletId -interface NFTReceiveComponent : ComposableContentComponent { +interface NFTComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, ) - interface Factory : ComponentFactory + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt similarity index 67% rename from features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt rename to features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt index 0f43e3a58f..676f626597 100644 --- a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt @@ -5,12 +5,13 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.wallets.models.UserWalletId -interface NFTDetailsComponent : ComposableContentComponent { +interface NFTDetailsBlockComponent : ComposableContentComponent { data class Params( val userWalletId: UserWalletId, val nftAsset: NFTAsset, + val nftCollectionName: String, ) - interface Factory : ComponentFactory + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index a33b3e1ad5..df09cb6d6a 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -27,6 +27,8 @@ dependencies { implementation(projects.core.datasource) /** Domain modules */ + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.models) implementation(projects.domain.nft) implementation(projects.domain.nft.models) implementation(projects.domain.tokens.models) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt similarity index 60% rename from features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt rename to features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt index bb82591e48..209883f972 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/NFTCollectionsComponent.kt @@ -6,17 +6,18 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.nft.collections.model.NFTCollectionsModel import com.tangem.features.nft.collections.ui.NFTCollections -import com.tangem.features.nft.component.NFTCollectionsComponent import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -internal class DefaultNFTCollectionsComponent @AssistedInject constructor( +internal class NFTCollectionsComponent @AssistedInject constructor( @Assisted context: AppComponentContext, - @Assisted private val params: NFTCollectionsComponent.Params, -) : NFTCollectionsComponent, AppComponentContext by context { + @Assisted private val params: Params, +) : ComposableContentComponent, AppComponentContext by context { private val model: NFTCollectionsModel = getOrCreateModel(params) @@ -27,11 +28,10 @@ internal class DefaultNFTCollectionsComponent @AssistedInject constructor( NFTCollections(state, modifier) } - @AssistedFactory - interface Factory : NFTCollectionsComponent.Factory { - override fun create( - context: AppComponentContext, - params: NFTCollectionsComponent.Params, - ): DefaultNFTCollectionsComponent - } + data class Params( + val userWalletId: UserWalletId, + val onBackClick: () -> Unit, + val onAssetClick: (asset: NFTAsset, collectionName: String) -> Unit, + val onReceiveClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt index e7e24ded3b..771f9515fc 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt @@ -5,7 +5,7 @@ import com.tangem.core.ui.extensions.TextReference internal data class NFTCollectionUM( val id: String, - val name: String, + val name: String?, @DrawableRes val networkIconId: Int, val logoUrl: String?, val description: TextReference, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt index e5fe0846c9..a0cfab6956 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -20,7 +20,7 @@ internal class UpdateDataStateTransformer( private val onRetryClick: () -> Unit, private val onExpandCollectionClick: (NFTCollection) -> Unit, private val onRetryAssetsClick: (NFTCollection) -> Unit, - private val onAssetClick: (NFTAsset) -> Unit, + private val onAssetClick: (NFTAsset, String) -> Unit, private val initialSearchBarFactory: () -> SearchBarUM, ) : Transformer { @@ -61,11 +61,29 @@ internal class UpdateDataStateTransformer( state: NFTCollectionsStateUM, query: String, ): ImmutableList = mapNotNull { - if (query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true) { + val assetsFulfillQuery = if (query.isEmpty()) { + true + } else { + when (val assets = it.assets) { + is NFTCollection.Assets.Empty, + is NFTCollection.Assets.Failed, + is NFTCollection.Assets.Loading, + -> false + is NFTCollection.Assets.Value -> { + assets.items.any { asset -> + asset.name?.lowercase()?.contains(query.lowercase()) == true + } + } + } + } + + val collectionFulfillQuery = query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true + + if (collectionFulfillQuery || assetsFulfillQuery) { NFTCollectionUM( id = it.id.toString(), networkIconId = getActiveIconRes(it.network.id.value), - name = it.name.orEmpty(), + name = it.name, description = TextReference.PluralRes( R.plurals.nft_collections_count, it.count, @@ -105,12 +123,12 @@ internal class UpdateDataStateTransformer( is NFTCollection.Assets.Value -> NFTCollectionAssetsListUM.Content( items = assets .items - .map { it.transform() } + .map { it.transform(name.orEmpty()) } .toPersistentList(), ) } - private fun NFTAsset.transform(): NFTCollectionAssetUM = NFTCollectionAssetUM( + private fun NFTAsset.transform(collectionName: String): NFTCollectionAssetUM = NFTCollectionAssetUM( id = id.toString(), name = name.orEmpty(), imageUrl = media?.url, @@ -121,7 +139,7 @@ internal class UpdateDataStateTransformer( is NFTSalePrice.Value -> NFTSalePriceUM.Content(salePrice.value.toString()) }, onItemClick = { - onAssetClick(this) + onAssetClick(this, collectionName) }, ) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt index 21618350db..2e1ddc7a19 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt @@ -1,24 +1,21 @@ package com.tangem.features.nft.collections.model -import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.nft.FetchNFTCollectionAssetsUseCase import com.tangem.domain.nft.GetNFTCollectionsUseCase -import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTCollection +import com.tangem.features.nft.collections.NFTCollectionsComponent import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM import com.tangem.features.nft.collections.entity.NFTCollectionsUM import com.tangem.features.nft.collections.entity.transformer.ChangeCollectionExpandedStateTransformer import com.tangem.features.nft.collections.entity.transformer.ToggleSearchBarTransformer import com.tangem.features.nft.collections.entity.transformer.UpdateDataStateTransformer import com.tangem.features.nft.collections.entity.transformer.UpdateSearchQueryTransformer -import com.tangem.features.nft.component.NFTCollectionsComponent import com.tangem.features.nft.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* @@ -29,23 +26,22 @@ import javax.inject.Inject @ModelScoped internal class NFTCollectionsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, private val searchManager: InputManager, private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase, private val fetchNFTCollectionAssetsUseCase: FetchNFTCollectionAssetsUseCase, paramsContainer: ParamsContainer, ) : Model() { - val state: StateFlow get() = _state + private val params: NFTCollectionsComponent.Params = paramsContainer.require() private val _state = MutableStateFlow( value = NFTCollectionsStateUM( - onBackClick = ::navigateBack, - content = NFTCollectionsUM.Loading(::onReceiveClick), + onBackClick = params.onBackClick, + content = NFTCollectionsUM.Loading(params.onBackClick), ), ) - private val params: NFTCollectionsComponent.Params = paramsContainer.require() + val state: StateFlow get() = _state init { subscribeToNFTCollections() @@ -60,11 +56,15 @@ internal class NFTCollectionsModel @Inject constructor( UpdateDataStateTransformer( nftCollections = nftCollections, searchQuery = query, - onReceiveClick = ::onReceiveClick, + onReceiveClick = { + params.onReceiveClick() + }, onRetryClick = ::onRetryClick, onExpandCollectionClick = ::onExpandCollectionClick, onRetryAssetsClick = ::onRetryAssetsClick, - onAssetClick = ::onAssetClick, + onAssetClick = { asset, collectionName -> + params.onAssetClick(asset, collectionName) + }, initialSearchBarFactory = ::getInitialSearchBar, ).transform(it) } @@ -115,27 +115,6 @@ internal class NFTCollectionsModel @Inject constructor( // TODO refresh all } - private fun onAssetClick(asset: NFTAsset) { - router.push( - AppRoute.NFTDetails( - userWalletId = params.userWalletId, - nftAsset = asset, - ), - ) - } - - private fun onReceiveClick() { - router.push( - AppRoute.NFTReceive( - userWalletId = params.userWalletId, - ), - ) - } - - private fun navigateBack() { - router.pop() - } - private fun loadCollectionAssets(collection: NFTCollection) { modelScope.launch { fetchNFTCollectionAssetsUseCase( diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt index ffaf0c94b1..3851542d90 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt @@ -2,7 +2,6 @@ package com.tangem.features.nft.collections.ui import android.content.res.Configuration import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon @@ -11,25 +10,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest -import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.nft.collections.entity.NFTCollectionAssetsListUM import com.tangem.features.nft.collections.entity.NFTCollectionUM +import com.tangem.features.nft.common.ui.NFTLogo import com.tangem.features.nft.impl.R import kotlinx.collections.immutable.persistentListOf @@ -52,7 +46,11 @@ internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier ), verticalAlignment = Alignment.CenterVertically, ) { - Logo(state) + NFTLogo( + imageUrl = state.logoUrl, + networkIconId = state.networkIconId, + background = TangemTheme.colors.background.primary, + ) Text(state) @@ -81,46 +79,6 @@ internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier } } -@Composable -private fun Logo(state: NFTCollectionUM) { - val networkBadgeOffset = TangemTheme.dimens.spacing6 - - Box( - modifier = Modifier, - ) { - SubcomposeAsyncImage( - modifier = Modifier - .align(Alignment.CenterStart) - .size(TangemTheme.dimens.size36) - .clip(TangemTheme.shapes.roundedCorners8), - model = ImageRequest.Builder(LocalContext.current) - .data(state.logoUrl) - .crossfade(true) - .build(), - loading = { - RectangleShimmer(radius = TangemTheme.dimens.radius8) - }, - error = { - Box( - modifier = Modifier - .clip(shape = TangemTheme.shapes.roundedCorners8) - .background(TangemTheme.colors.field.primary), - ) - }, - contentScale = ContentScale.Crop, - contentDescription = null, - ) - CurrencyIconTopBadge( - modifier = Modifier - .offset(x = networkBadgeOffset, y = -networkBadgeOffset) - .align(Alignment.TopEnd), - iconResId = state.networkIconId, - alpha = 1f, - colorFilter = null, - ) - } -} - @Composable private fun RowScope.Text(state: NFTCollectionUM) { Column( @@ -132,7 +90,7 @@ private fun RowScope.Text(state: NFTCollectionUM) { ) { Text( modifier = Modifier, - text = state.name, + text = state.name.takeUnless { it.isNullOrEmpty() } ?: stringResourceSafe(R.string.nft_no_collection), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, maxLines = 1, diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt index e1301e1146..bb30235ff8 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt @@ -9,11 +9,13 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.PrimaryButton @@ -36,6 +38,8 @@ import kotlinx.collections.immutable.persistentListOf internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: Modifier = Modifier) { val listState = rememberLazyListState() + val bottomPadding = TangemTheme.dimens.spacing60 + Box( modifier = modifier .fillMaxSize() @@ -54,55 +58,72 @@ internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: state = content.search, colors = TangemSearchBarDefaults.secondaryTextFieldColors, ) - content.warnings.fastForEach { - key(it.id) { - NFTCollectionWarning( + if (content.collections.isEmpty()) { + Box( + modifier = modifier + .fillMaxSize() + .padding(bottom = bottomPadding), + ) { + Text( modifier = Modifier - .padding(top = TangemTheme.dimens.spacing16), - state = it, + .align(Alignment.Center), + text = stringResourceSafe(id = R.string.nft_empty_search), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, ) } - } - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding( - top = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing60, - ) - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.primary), - state = listState, - ) { - content.collections.fastForEach { collection -> - item(key = collection.id) { - NFTCollection( - modifier = Modifier.fillMaxWidth(), - state = collection, + } else { + content.warnings.fastForEach { + key(it.id) { + NFTCollectionWarning( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16), + state = it, ) } - when (val assets = collection.assets) { - is NFTCollectionAssetsListUM.Init -> Unit - is NFTCollectionAssetsListUM.Loading -> { - assetsListLoading( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing16, + bottom = bottomPadding, + ) + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary), + state = listState, + ) { + content.collections.fastForEach { collection -> + item(key = collection.id) { + NFTCollection( + modifier = Modifier.fillMaxWidth(), + state = collection, ) } - is NFTCollectionAssetsListUM.Failed -> { - assetsListFailed( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, - ) - } - is NFTCollectionAssetsListUM.Content -> { - assetsListContent( - collectionId = collection.id, - content = assets, - expanded = collection.isExpanded, - ) + when (val assets = collection.assets) { + is NFTCollectionAssetsListUM.Init -> Unit + is NFTCollectionAssetsListUM.Loading -> { + assetsListLoading( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + ) + } + is NFTCollectionAssetsListUM.Failed -> { + assetsListFailed( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + ) + } + is NFTCollectionAssetsListUM.Content -> { + assetsListContent( + collectionId = collection.id, + content = assets, + expanded = collection.isExpanded, + ) + } } } } diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt new file mode 100644 index 0000000000..95e4a2ad8f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt @@ -0,0 +1,175 @@ +package com.tangem.features.nft.common + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.value.ObserveLifecycleMode +import com.arkivanov.decompose.value.subscribe +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.nft.collections.NFTCollectionsComponent +import com.tangem.features.nft.common.ui.NFTContent +import com.tangem.features.nft.component.NFTComponent +import com.tangem.features.nft.details.NFTDetailsComponent +import com.tangem.features.nft.details.info.NFTDetailsInfoComponent +import com.tangem.features.nft.receive.NFTReceiveComponent +import com.tangem.features.nft.traits.NFTAssetTraitsComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +internal class DefaultNFTComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: NFTComponent.Params, + private val nftDetailsInfoComponentFactory: NFTDetailsInfoComponent.Factory, +) : NFTComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val initialRoute: NFTRoute = NFTRoute.Collections(params.userWalletId) + private val currentRoute = MutableStateFlow(initialRoute) + + private val childStack = childStack( + key = "sendInnerStack", + source = stackNavigation, + serializer = null, + initialConfiguration = initialRoute, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + createChild( + configuration, + childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + ) + }, + ) + + init { + childStack.subscribe( + lifecycle = lifecycle, + mode = ObserveLifecycleMode.CREATE_DESTROY, + ) { stack -> + componentScope.launch { + currentRoute.emit(stack.active.configuration) + } + } + } + + @Composable + override fun Content(modifier: Modifier) { + val stackState by childStack.subscribeAsState() + + BackHandler(onBack = ::onChildBack) + NFTContent( + stackState = stackState, + ) + } + + private fun createChild(route: NFTRoute, factoryContext: AppComponentContext) = when (route) { + is NFTRoute.Collections -> getCollectionsComponent(factoryContext, route) + is NFTRoute.Receive -> getReceiveComponent(factoryContext, route) + is NFTRoute.Details -> getDetailsComponent(factoryContext, route) + is NFTRoute.AssetTraits -> getAssetTraitsComponent(factoryContext, route) + } + + private fun getCollectionsComponent( + factoryContext: AppComponentContext, + route: NFTRoute.Collections, + ): ComposableContentComponent = NFTCollectionsComponent( + context = factoryContext, + params = NFTCollectionsComponent.Params( + userWalletId = route.userWalletId, + onBackClick = ::onChildBack, + onReceiveClick = { + innerRouter.push( + NFTRoute.Receive( + userWalletId = route.userWalletId, + ), + ) + }, + onAssetClick = { asset, collectionName -> + innerRouter.push( + NFTRoute.Details( + userWalletId = route.userWalletId, + nftAsset = asset, + collectionName = collectionName, + ), + ) + }, + ), + ) + + private fun getReceiveComponent( + factoryContext: AppComponentContext, + route: NFTRoute.Receive, + ): ComposableContentComponent = NFTReceiveComponent( + context = factoryContext, + params = NFTReceiveComponent.Params( + userWalletId = route.userWalletId, + onBackClick = ::onChildBack, + ), + ) + + private fun getDetailsComponent( + factoryContext: AppComponentContext, + route: NFTRoute.Details, + ): ComposableContentComponent = NFTDetailsComponent( + context = factoryContext, + params = NFTDetailsComponent.Params( + userWalletId = route.userWalletId, + nftAsset = route.nftAsset, + nftCollectionName = route.collectionName, + onBackClick = ::onChildBack, + onAllTraitsClick = { + innerRouter.push( + NFTRoute.AssetTraits( + nftAsset = route.nftAsset, + ), + ) + }, + ), + nftDetailsInfoComponentFactory = nftDetailsInfoComponentFactory, + ) + + private fun getAssetTraitsComponent( + factoryContext: AppComponentContext, + route: NFTRoute.AssetTraits, + ): ComposableContentComponent = NFTAssetTraitsComponent( + context = factoryContext, + params = NFTAssetTraitsComponent.Params( + nftAsset = route.nftAsset, + onBackClick = ::onChildBack, + ), + ) + + private fun onChildBack() { + val isEmptyStack = childStack.value.backStack.isEmpty() + + if (isEmptyStack) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : NFTComponent.Factory { + override fun create(context: AppComponentContext, params: NFTComponent.Params): DefaultNFTComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt new file mode 100644 index 0000000000..5c3482f2f6 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/NFTRoute.kt @@ -0,0 +1,31 @@ +package com.tangem.features.nft.common + +import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.serialization.Serializable + +internal sealed class NFTRoute : Route { + + @Serializable + data class Collections( + val userWalletId: UserWalletId, + ) : NFTRoute() + + @Serializable + data class Receive( + val userWalletId: UserWalletId, + ) : NFTRoute() + + @Serializable + data class Details( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val collectionName: String, + ) : NFTRoute() + + @Serializable + data class AssetTraits( + val nftAsset: NFTAsset, + ) : NFTRoute() +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt new file mode 100644 index 0000000000..ef907f1e0f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTContent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.nft.common.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.common.NFTRoute + +@Composable +internal fun NFTContent(stackState: ChildStack) { + Column( + modifier = Modifier.Companion + .background(color = TangemTheme.colors.background.tertiary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.Companion.CenterHorizontally, + ) { + Children( + stack = stackState, + animation = stackAnimation(slide()), + modifier = Modifier.weight(1f), + ) { + it.instance.Content(Modifier.weight(1f)) + } + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTLogo.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTLogo.kt new file mode 100644 index 0000000000..db5f6d01ca --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTLogo.kt @@ -0,0 +1,60 @@ +package com.tangem.features.nft.common.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun NFTLogo(imageUrl: String?, @DrawableRes networkIconId: Int, background: Color = Color.Transparent) { + val networkBadgeOffset = TangemTheme.dimens.spacing6 + + Box( + modifier = Modifier, + ) { + SubcomposeAsyncImage( + modifier = Modifier + .align(Alignment.CenterStart) + .size(TangemTheme.dimens.size36) + .clip(TangemTheme.shapes.roundedCorners8), + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + loading = { + RectangleShimmer(radius = TangemTheme.dimens.radius8) + }, + error = { + Box( + modifier = Modifier + .clip(shape = TangemTheme.shapes.roundedCorners8) + .background(TangemTheme.colors.field.primary), + ) + }, + contentScale = ContentScale.Crop, + contentDescription = null, + ) + CurrencyIconTopBadge( + modifier = Modifier + .offset(x = networkBadgeOffset, y = -networkBadgeOffset) + .align(Alignment.TopEnd), + iconResId = networkIconId, + alpha = 1f, + colorFilter = null, + background = background, + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt deleted file mode 100644 index bb220d9f9f..0000000000 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.features.nft.details - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.nft.component.NFTDetailsComponent -import com.tangem.features.nft.details.model.NFTDetailsModel -import com.tangem.features.nft.details.ui.NFTDetails -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultNFTDetailsComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted private val params: NFTDetailsComponent.Params, -) : NFTDetailsComponent, AppComponentContext by context { - - private val model: NFTDetailsModel = getOrCreateModel(params) - - @Composable - override fun Content(modifier: Modifier) { - val state by model.state.collectAsStateWithLifecycle() - - NFTDetails(state, modifier) - } - - @AssistedFactory - interface Factory : NFTDetailsComponent.Factory { - override fun create( - context: AppComponentContext, - params: NFTDetailsComponent.Params, - ): DefaultNFTDetailsComponent - } -} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/NFTDetailsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/NFTDetailsComponent.kt new file mode 100644 index 0000000000..2333a6a0e0 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/NFTDetailsComponent.kt @@ -0,0 +1,71 @@ +package com.tangem.features.nft.details + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.nft.details.entity.NFTDetailsBottomSheetConfig +import com.tangem.features.nft.details.info.NFTDetailsInfoComponent +import com.tangem.features.nft.details.model.NFTDetailsModel +import com.tangem.features.nft.details.ui.NFTDetails +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +internal class NFTDetailsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, + private val nftDetailsInfoComponentFactory: NFTDetailsInfoComponent.Factory, +) : ComposableContentComponent, AppComponentContext by context { + + private val model: NFTDetailsModel = getOrCreateModel(params) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + NFTDetails(state, modifier) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: NFTDetailsBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + is NFTDetailsBottomSheetConfig.Info -> nftDetailsInfoComponentFactory.create( + context = childByContext(componentContext), + params = NFTDetailsInfoComponent.Params( + text = config.text, + onDismiss = { + model.bottomSheetNavigation.dismiss() + }, + ), + ) + } + + data class Params( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val nftCollectionName: String, + val onBackClick: () -> Unit, + val onAllTraitsClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt new file mode 100644 index 0000000000..1c20de2872 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.nft.details.block + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.nft.details.block.ui.NFTDetailsBlock +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +class DefaultNFTDetailsBlockComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NFTDetailsBlockComponent.Params, +) : NFTDetailsBlockComponent, AppComponentContext by context { + + @Composable + override fun Content(modifier: Modifier) { + NFTDetailsBlock( + assetName = stringReference(params.nftAsset.name.orEmpty()), + collectionName = stringReference(params.nftCollectionName), + assetImage = params.nftAsset.media?.url, + networkIconRes = getActiveIconRes(params.nftAsset.network.id.value), + ) + } + + @AssistedFactory + interface Factory : NFTDetailsBlockComponent.Factory { + override fun create( + context: AppComponentContext, + params: NFTDetailsBlockComponent.Params, + ): DefaultNFTDetailsBlockComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt new file mode 100644 index 0000000000..6336d2dbb2 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt @@ -0,0 +1,84 @@ +package com.tangem.features.nft.details.block.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.nft.common.ui.NFTLogo +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTDetailsBlock( + assetName: TextReference, + collectionName: TextReference, + assetImage: String?, + networkIconRes: Int, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.action) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "NFT Asset", + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NFTLogo( + assetImage, + networkIconRes, + background = TangemTheme.colors.background.action, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = assetName.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = collectionName.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun NFTDetailsBlock_Preview() { + TangemThemePreview { + NFTDetailsBlock( + assetName = stringReference("NFT Asset Name"), + collectionName = stringReference("NFT Collection"), + assetImage = null, + networkIconRes = R.drawable.img_polygon_22, + ) + } +} +// endregion \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt index 2b85b58fe8..22243c075c 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt @@ -3,7 +3,9 @@ package com.tangem.features.nft.details.entity import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency import kotlinx.collections.immutable.ImmutableList +import java.math.BigDecimal data class NFTAssetUM( val name: String, @@ -37,7 +39,13 @@ data class NFTAssetUM( sealed class SalePrice { data object Loading : SalePrice() data object Empty : SalePrice() - data class Content(val value: String, val fiatValue: String) : SalePrice() + data class Content( + val value: BigDecimal, + val symbol: String, + val decimals: Int, + val rate: BigDecimal?, + val appCurrency: AppCurrency, + ) : SalePrice() } @Immutable diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsBottomSheetConfig.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsBottomSheetConfig.kt new file mode 100644 index 0000000000..e8478f38ac --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsBottomSheetConfig.kt @@ -0,0 +1,12 @@ +package com.tangem.features.nft.details.entity + +import com.tangem.core.ui.extensions.TextReference +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface NFTDetailsBottomSheetConfig { + @Serializable + data class Info( + val text: TextReference, + ) : NFTDetailsBottomSheetConfig +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt index 55df42f2df..95844969ae 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt @@ -1,13 +1,10 @@ package com.tangem.features.nft.details.entity -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig - internal data class NFTDetailsUM( val nftAsset: NFTAssetUM, val onBackClick: () -> Unit, val onReadMoreClick: () -> Unit, - val onSeeAllClick: () -> Unit, + val onSeeAllTraitsClick: () -> Unit, val onExploreClick: () -> Unit, val onSendClick: () -> Unit, - val bottomSheetConfig: TangemBottomSheetConfig?, ) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/factory/NFTDetailsUMFactory.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/factory/NFTDetailsUMFactory.kt new file mode 100644 index 0000000000..f23446ab57 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/factory/NFTDetailsUMFactory.kt @@ -0,0 +1,126 @@ +package com.tangem.features.nft.details.entity.factory + +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.nft.models.NFTSalePrice +import com.tangem.features.nft.details.entity.NFTAssetUM +import com.tangem.features.nft.details.entity.NFTDetailsUM +import com.tangem.features.nft.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class NFTDetailsUMFactory( + private val onBackClick: () -> Unit, + private val onReadMoreClick: () -> Unit, + private val onSeeAllTraitsClick: () -> Unit, + private val onExploreClick: () -> Unit, + private val onSendClick: () -> Unit, +) { + + fun getInitialState(nftAsset: NFTAsset): NFTDetailsUM = NFTDetailsUM( + nftAsset = nftAsset.transform(), + onBackClick = onBackClick, + onReadMoreClick = onReadMoreClick, + onSeeAllTraitsClick = onSeeAllTraitsClick, + onExploreClick = onExploreClick, + onSendClick = onSendClick, + ) + + private fun NFTAsset.transform(): NFTAssetUM = NFTAssetUM( + name = name.orEmpty(), + media = media?.let { + NFTAssetUM.Media.Content( + mimetype = it.mimetype, + url = it.url, + ) + } ?: NFTAssetUM.Media.Empty, + topInfo = when { + !hasSalePrice() && description.isNullOrEmpty() && rarity == null -> { + NFTAssetUM.TopInfo.Empty + } + else -> { + val hasSalePrice = hasSalePrice() + val hasDescription = !description.isNullOrEmpty() + val rarity = rarity + NFTAssetUM.TopInfo.Content( + title = if (hasSalePrice) { + resourceReference(R.string.nft_details_last_sale_price) + } else { + null + }, + salePrice = NFTAssetUM.SalePrice.Empty, + description = description, + rarity = if (rarity != null) { + NFTAssetUM.Rarity.Content( + rank = rarity.rank, + label = rarity.label, + showDivider = hasSalePrice || hasDescription, + ) + } else { + NFTAssetUM.Rarity.Empty + }, + ) + } + }, + traits = traits.take(MAX_TRAITS_COUNT).map { + NFTAssetUM.BlockItem( + title = stringReference(it.name), + value = it.value, + ) + }.toImmutableList(), + baseInfoItems = buildBaseInfoItems(), + ) + + private fun NFTAsset.hasSalePrice() = salePrice !is NFTSalePrice.Empty && salePrice !is NFTSalePrice.Error + + private fun NFTAsset.buildBaseInfoItems() = when (val id = id) { + is NFTAsset.Identifier.EVM -> persistentListOf( + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_standard), + value = contractType, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_contract_address), + value = id.tokenAddress, + valueTextEllipsis = TextEllipsis.Middle, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_id), + value = id.tokenId, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_chain), + value = network.name, + ), + ) + is NFTAsset.Identifier.TON -> persistentListOf( + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_address), + value = id.tokenAddress, + valueTextEllipsis = TextEllipsis.Middle, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_chain), + value = network.name, + ), + ) + is NFTAsset.Identifier.Solana -> persistentListOf( + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_address), + value = id.tokenAddress, + valueTextEllipsis = TextEllipsis.Middle, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_chain), + value = network.name, + ), + ) + is NFTAsset.Identifier.Unknown -> persistentListOf() + } + + companion object { + private const val MAX_TRAITS_COUNT = 6 + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/DefaultNFTDetailsInfoComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/DefaultNFTDetailsInfoComponent.kt new file mode 100644 index 0000000000..26dc1a1dd6 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/DefaultNFTDetailsInfoComponent.kt @@ -0,0 +1,54 @@ +package com.tangem.features.nft.details.info + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.details.info.ui.NFTInfoBottomSheet +import com.tangem.features.nft.details.info.ui.NFTInfoBottomSheetContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNFTDetailsInfoComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NFTDetailsInfoComponent.Params, +) : NFTDetailsInfoComponent, AppComponentContext by context { + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val bottomSheetConfig = remember(key1 = this) { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, + ) + } + NFTInfoBottomSheet( + config = bottomSheetConfig, + content = { + NFTInfoBottomSheetContent( + text = params.text, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + }, + ) + } + + @AssistedFactory + interface Factory : NFTDetailsInfoComponent.Factory { + override fun create( + context: AppComponentContext, + params: NFTDetailsInfoComponent.Params, + ): DefaultNFTDetailsInfoComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/NFTDetailsInfoComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/NFTDetailsInfoComponent.kt new file mode 100644 index 0000000000..90650da63b --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/NFTDetailsInfoComponent.kt @@ -0,0 +1,15 @@ +package com.tangem.features.nft.details.info + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.extensions.TextReference + +internal interface NFTDetailsInfoComponent : ComposableBottomSheetComponent { + + data class Params( + val text: TextReference, + val onDismiss: () -> Unit, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/ui/NFTInfoBottomSheet.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/ui/NFTInfoBottomSheet.kt new file mode 100644 index 0000000000..b95179836f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/info/ui/NFTInfoBottomSheet.kt @@ -0,0 +1,69 @@ +package com.tangem.features.nft.details.info.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +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.nft.impl.R + +@Composable +internal fun NFTInfoBottomSheet(config: TangemBottomSheetConfig, content: @Composable () -> Unit) { + TangemBottomSheet( + config = config, + titleText = resourceReference(R.string.nft_about_title), + content = { content() }, + ) +} + +@Composable +internal fun NFTInfoBottomSheetContent(text: TextReference, modifier: Modifier = Modifier) { + val scrollState = rememberScrollState() + Column( + modifier = modifier.verticalScroll(scrollState), + ) { + Text( + text = text.resolveReference(), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2, + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) + } +} + +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_NFTInfoBottomSheetContent() { + TangemThemePreview { + NFTInfoBottomSheetContent( + text = stringReference( + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce varius neque vel ligula + tincidunt, nec faucibus nulla ultricies. Maecenas euismod arcu in nunc volutpat, + at bibendum eros lacinia. Proin hendrerit massa non velit congue, + in volutpat nisi consequat. Sed vitae justo nec orci tincidunt malesuada. + Nullam feugiat purus vel lectus efficitur, vel fringilla urna volutpat. + Donec sagittis enim in metus lacinia, vel tempor nunc bibendum. + """.trimIndent(), + ), + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt index e421efcde8..e81bb43891 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt @@ -1,64 +1,94 @@ package com.tangem.features.nft.details.model +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router -import com.tangem.features.nft.component.NFTDetailsComponent +import com.tangem.domain.nft.analytics.NFTAnalyticsEvent +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.nft.GetNFTExploreUrlUseCase +import com.tangem.features.nft.details.NFTDetailsComponent import com.tangem.features.nft.details.entity.NFTAssetUM +import com.tangem.features.nft.details.entity.NFTDetailsBottomSheetConfig import com.tangem.features.nft.details.entity.NFTDetailsUM +import com.tangem.features.nft.details.entity.factory.NFTDetailsUMFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped internal class NFTDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val router: Router, + private val analyticsEventHandler: AnalyticsEventHandler, + private val urlOpener: UrlOpener, + private val getNFTExploreUrlUseCase: GetNFTExploreUrlUseCase, paramsContainer: ParamsContainer, ) : Model() { - @Suppress("UnusedPrivateMember") private val params: NFTDetailsComponent.Params = paramsContainer.require() val state: StateFlow get() = _state - private val _state = MutableStateFlow( - value = NFTDetailsUM( - nftAsset = NFTAssetUM( - name = "", - media = NFTAssetUM.Media.Empty, - topInfo = NFTAssetUM.TopInfo.Empty, - traits = persistentListOf(), - baseInfoItems = persistentListOf(), - ), - onBackClick = ::navigateBack, - onReadMoreClick = ::onReadMoreClick, - onSeeAllClick = ::onSeeAllClick, - onExploreClick = ::onExploreClick, - onSendClick = ::onSendClick, - bottomSheetConfig = null, - ), + private val stateFactory: NFTDetailsUMFactory = NFTDetailsUMFactory( + onBackClick = { params.onBackClick() }, + onReadMoreClick = ::onReadMoreClick, + onSeeAllTraitsClick = { params.onAllTraitsClick() }, + onExploreClick = ::onExploreClick, + onSendClick = ::onSendClick, ) - private fun onReadMoreClick() { - // TODO implement + private val _state = MutableStateFlow( + value = stateFactory.getInitialState(params.nftAsset), + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + init { + analyticsEventHandler.send(NFTAnalyticsEvent.Details.ScreenOpened(params.nftAsset.network.name)) } - private fun onSeeAllClick() { - // TODO implement + private fun onReadMoreClick() { + analyticsEventHandler.send(NFTAnalyticsEvent.Details.ButtonReadMore) + when (val topInfo = _state.value.nftAsset.topInfo) { + is NFTAssetUM.TopInfo.Empty -> Unit + is NFTAssetUM.TopInfo.Content -> { + bottomSheetNavigation.activate( + NFTDetailsBottomSheetConfig.Info( + text = stringReference(topInfo.description.orEmpty()), + ), + ) + } + } } private fun onExploreClick() { - // TODO implement + analyticsEventHandler.send(NFTAnalyticsEvent.Details.ButtonExplore) + modelScope.launch { + val url = getNFTExploreUrlUseCase.invoke( + network = params.nftAsset.network, + assetIdentifier = params.nftAsset.id, + ) + if (url != null) { + urlOpener.openUrl(url) + } + } } private fun onSendClick() { - // TODO implement - } - - private fun navigateBack() { - router.pop() + analyticsEventHandler.send(NFTAnalyticsEvent.Details.ButtonSend) + router.push( + AppRoute.NFTSend( + userWalletId = params.userWalletId, + nftAsset = params.nftAsset, + nftCollectionName = params.nftCollectionName, + ), + ) } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt index 31af37f744..b662151b8d 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt @@ -37,7 +37,7 @@ internal fun NFTDetails(state: NFTDetailsUM, modifier: Modifier = Modifier) { NFTDetailsAsset( state = state.nftAsset, onReadMoreClick = state.onReadMoreClick, - onSeeAllClick = state.onSeeAllClick, + onSeeAllTraitsClick = state.onSeeAllTraitsClick, onExploreClick = state.onExploreClick, modifier = Modifier .padding(innerPadding), diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt index b3806888f4..233c56d281 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt @@ -16,15 +16,17 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.features.nft.details.entity.NFTAssetUM import com.tangem.features.nft.impl.R import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal @Composable internal fun NFTDetailsAsset( state: NFTAssetUM, onReadMoreClick: () -> Unit, - onSeeAllClick: () -> Unit, + onSeeAllTraitsClick: () -> Unit, onExploreClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -72,7 +74,7 @@ internal fun NFTDetailsAsset( NFTBlocksGroupAction( text = resourceReference(R.string.common_see_all), startIcon = { }, - onClick = onSeeAllClick, + onClick = onSeeAllTraitsClick, ) }, ) @@ -103,7 +105,7 @@ private fun Preview_NFTDetailsAssetAsset(@PreviewParameter(NFTAssetProvider::cla NFTDetailsAsset( state = state, onReadMoreClick = { }, - onSeeAllClick = { }, + onSeeAllTraitsClick = { }, onExploreClick = { }, ) } @@ -120,7 +122,13 @@ private class NFTAssetProvider : CollectionPreviewParameterProvider( ), topInfo = NFTAssetUM.TopInfo.Content( title = resourceReference(R.string.nft_details_last_sale_price), - salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"), + salePrice = NFTAssetUM.SalePrice.Content( + value = BigDecimal("18.75"), + symbol = "ETH", + decimals = 18, + rate = BigDecimal("1"), + appCurrency = AppCurrency.Default, + ), description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3), rarity = NFTAssetUM.Rarity.Content( rank = "Top 1% rarity", diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt index aa34278977..8f71b0c87c 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt @@ -3,24 +3,34 @@ package com.tangem.features.nft.details.ui import android.content.res.Configuration import androidx.compose.animation.* import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp +import com.tangem.common.ui.amountScreen.utils.getFiatString import com.tangem.core.ui.components.TextShimmer -import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.ReadMoreText import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.features.nft.details.entity.NFTAssetUM import com.tangem.features.nft.impl.R +import java.math.BigDecimal + +private const val READ_MORE_MAX_LINES = 3 @Composable internal fun NFTDetailsInfoGroup( @@ -41,7 +51,10 @@ internal fun NFTDetailsInfoGroup( contentHorizontalPadding = 0.dp, ) { Column( - modifier = Modifier, + modifier = Modifier + .padding( + bottom = TangemTheme.dimens.spacing12, + ), ) { SalePrice(state.salePrice) Description(state.description, onReadMoreClick) @@ -68,7 +81,6 @@ private fun SalePrice(state: NFTAssetUM.SalePrice, modifier: Modifier = Modifier .padding( start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing12, ), ) { TextShimmer( @@ -92,19 +104,23 @@ private fun SalePrice(state: NFTAssetUM.SalePrice, modifier: Modifier = Modifier .padding( start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing12, ), verticalArrangement = Arrangement.SpaceAround, ) { Text( - text = state.value, + text = state.value.format { + crypto( + symbol = state.symbol, + decimals = state.decimals, + ) + }, style = TangemTheme.typography.head, color = TangemTheme.colors.text.primary1, ) Text( modifier = Modifier .padding(top = TangemTheme.dimens.spacing4), - text = state.fiatValue, + text = getFiatString(state.value, state.rate, state.appCurrency), style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, ) @@ -117,18 +133,29 @@ private fun SalePrice(state: NFTAssetUM.SalePrice, modifier: Modifier = Modifier @Composable private fun Description(description: String?, onReadMoreClick: () -> Unit, modifier: Modifier = Modifier) { if (!description.isNullOrEmpty()) { - // TODO configure right way to display 3 rows and "Read more" label - EllipsisText( + val interactionSource = remember { MutableInteractionSource() } + ReadMoreText( modifier = modifier - .clickable { onReadMoreClick() } + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onReadMoreClick, + ) .padding( - horizontal = TangemTheme.dimens.spacing12, - vertical = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, ), + expanded = false, text = description, style = TangemTheme.typography.body2.copy( color = TangemTheme.colors.text.tertiary, ), + readMoreText = resourceReference(R.string.common_read_more).resolveReference(), + readMoreMaxLines = READ_MORE_MAX_LINES, + readMoreStyle = TangemTheme.typography.body2.copy( + color = TangemTheme.colors.text.accent, + ).toSpanStyle(), ) } } @@ -141,7 +168,9 @@ private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { if (state.showDivider) { HorizontalDivider( modifier = Modifier.padding( - horizontal = TangemTheme.dimens.spacing12, + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing8, + end = TangemTheme.dimens.spacing12, ), color = TangemTheme.colors.stroke.primary, ) @@ -150,8 +179,9 @@ private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { modifier = modifier .fillMaxWidth() .padding( - horizontal = TangemTheme.dimens.spacing6, - vertical = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing6, + top = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing6, ), ) { NFTDetailsGroupBlock( @@ -159,7 +189,6 @@ private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { .weight(1f) .padding( horizontal = TangemTheme.dimens.spacing6, - vertical = TangemTheme.dimens.spacing4, ), title = resourceReference(R.string.nft_details_rarity_label), value = stringReference(state.label), @@ -169,7 +198,6 @@ private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { .weight(1f) .padding( horizontal = TangemTheme.dimens.spacing6, - vertical = TangemTheme.dimens.spacing4, ), title = resourceReference(R.string.nft_details_rarity_rank), value = stringReference(state.rank), @@ -196,7 +224,13 @@ private class NFTAssetInfoProvider : CollectionPreviewParameterProvider Unit, + ) } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt index dc0eefd9b3..3d62d4c7e2 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt @@ -1,9 +1,9 @@ package com.tangem.features.nft.receive.model +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.components.fields.InputManager @@ -12,10 +12,11 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.nft.FilterNFTAvailableNetworksUseCase import com.tangem.domain.nft.GetNFTAvailableNetworksUseCase import com.tangem.domain.nft.GetNFTNetworkStatusUseCase +import com.tangem.domain.nft.analytics.NFTAnalyticsEvent import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.NetworkStatus -import com.tangem.features.nft.component.NFTReceiveComponent import com.tangem.features.nft.impl.R +import com.tangem.features.nft.receive.NFTReceiveComponent import com.tangem.features.nft.receive.entity.NFTReceiveUM import com.tangem.features.nft.receive.entity.transformer.ShowReceiveBottomSheetTransformer import com.tangem.features.nft.receive.entity.transformer.ToggleSearchBarTransformer @@ -31,30 +32,31 @@ import javax.inject.Inject @ModelScoped internal class NFTReceiveModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, private val searchManager: InputManager, private val getNFTAvailableNetworksUseCase: GetNFTAvailableNetworksUseCase, private val filterNFTAvailableNetworksUseCase: FilterNFTAvailableNetworksUseCase, private val getNFTNetworkStatusUseCase: GetNFTNetworkStatusUseCase, private val clipboardManager: ClipboardManager, private val shareManager: ShareManager, + private val analyticsEventHandler: AnalyticsEventHandler, paramsContainer: ParamsContainer, ) : Model() { - val state: StateFlow get() = _state + private val params: NFTReceiveComponent.Params = paramsContainer.require() private val _state = MutableStateFlow( value = NFTReceiveUM( - onBackClick = ::navigateBack, + onBackClick = params.onBackClick, search = getInitialSearchBar(), networks = NFTReceiveUM.Networks.Content(persistentListOf()), bottomSheetConfig = null, ), ) - private val params: NFTReceiveComponent.Params = paramsContainer.require() + val state: StateFlow get() = _state init { + analyticsEventHandler.send(NFTAnalyticsEvent.Receive.ScreenOpened) subscribeToNFTAvailableNetworks() } @@ -108,6 +110,8 @@ internal class NFTReceiveModel @Inject constructor( private fun onNetworkClick(network: Network) { modelScope.launch { + analyticsEventHandler.send(NFTAnalyticsEvent.Receive.BlockchainChosen(network.name)) + val networkStatus = getNFTNetworkStatusUseCase.invoke( userWalletId = params.userWalletId, network = network, @@ -120,8 +124,8 @@ internal class NFTReceiveModel @Inject constructor( network = network, networkAddress = value.address, onDismissBottomSheet = ::onReceiveBottomSheetDismiss, - onCopyClick = ::onCopyClick, - onShareClick = ::onShareClick, + onCopyClick = { text -> onCopyClick(text, network) }, + onShareClick = { text -> onShareClick(text, network) }, ).transform(it) } } @@ -133,15 +137,13 @@ internal class NFTReceiveModel @Inject constructor( } } - private fun onCopyClick(text: String) { + private fun onCopyClick(text: String, network: Network) { + analyticsEventHandler.send(NFTAnalyticsEvent.Receive.CopyAddress(network.name)) clipboardManager.setText(text = text, isSensitive = true) } - private fun onShareClick(text: String) { + private fun onShareClick(text: String, network: Network) { + analyticsEventHandler.send(NFTAnalyticsEvent.Receive.ShareAddress(network.name)) shareManager.shareText(text = text) } - - private fun navigateBack() { - router.pop() - } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt index cebaa5a91d..931138a97d 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt @@ -1,7 +1,11 @@ package com.tangem.features.nft.receive.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -9,10 +13,17 @@ import com.tangem.features.nft.impl.R @Composable internal fun NFTReceiveNetworksEmpty(modifier: Modifier = Modifier) { - Text( - modifier = modifier, - text = stringResourceSafe(id = R.string.nft_empty_search), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.tertiary, - ) + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary), + ) { + Text( + modifier = Modifier + .align(Alignment.Center), + text = stringResourceSafe(id = R.string.nft_empty_search), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/NFTAssetTraitsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/NFTAssetTraitsComponent.kt new file mode 100644 index 0000000000..ec54b5846d --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/NFTAssetTraitsComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.nft.traits + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.features.nft.traits.model.NFTAssetTraitsModel +import com.tangem.features.nft.traits.ui.NFTAssetTraits +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +internal class NFTAssetTraitsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : ComposableContentComponent, AppComponentContext by context { + + private val model: NFTAssetTraitsModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + NFTAssetTraits(state) + } + + data class Params( + val nftAsset: NFTAsset, + val onBackClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt new file mode 100644 index 0000000000..1837fa3f30 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt @@ -0,0 +1,7 @@ +package com.tangem.features.nft.traits.entity + +data class NFTAssetTraitUM( + val id: String, + val name: String, + val value: String, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt new file mode 100644 index 0000000000..11b3fd9eea --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.nft.traits.entity + +import kotlinx.collections.immutable.ImmutableList + +data class NFTAssetTraitsUM( + val onBackClick: () -> Unit, + val traits: ImmutableList, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt new file mode 100644 index 0000000000..52ed09006f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt @@ -0,0 +1,42 @@ +package com.tangem.features.nft.traits.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.features.nft.traits.NFTAssetTraitsComponent +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@ModelScoped +internal class NFTAssetTraitsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: NFTAssetTraitsComponent.Params = paramsContainer.require() + + private val _state = MutableStateFlow( + value = NFTAssetTraitsUM( + traits = params.nftAsset.transform(), + onBackClick = { params.onBackClick() }, + ), + ) + + val state: StateFlow get() = _state + + private fun NFTAsset.transform(): ImmutableList = this + .traits + .mapIndexed { index, trait -> + NFTAssetTraitUM( + id = index.toString(), + name = trait.name, + value = trait.value, + ) + }.toPersistentList() +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt new file mode 100644 index 0000000000..dc9135f37c --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt @@ -0,0 +1,29 @@ +package com.tangem.features.nft.traits.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM + +@Composable +internal fun NFTAssetTrait(state: NFTAssetTraitUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + ) { + Text( + text = state.name, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing4), + text = state.value, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt new file mode 100644 index 0000000000..f6e27276f2 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt @@ -0,0 +1,41 @@ +package com.tangem.features.nft.traits.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.impl.R +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM + +@Composable +internal fun NFTAssetTraits(state: NFTAssetTraitsUM, modifier: Modifier = Modifier) { + BackHandler(onBack = state.onBackClick) + + Scaffold( + modifier = modifier, + containerColor = TangemTheme.colors.background.secondary, + topBar = { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + startButton = TopAppBarButtonUM( + iconRes = R.drawable.ic_back_24, + onIconClicked = state.onBackClick, + ), + title = stringResourceSafe(R.string.nft_traits_title), + ) + }, + content = { innerPadding -> + NFTAssetTraitsContent( + modifier = Modifier + .padding(innerPadding), + state = state, + ) + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt new file mode 100644 index 0000000000..c51f428b21 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt @@ -0,0 +1,94 @@ +package com.tangem.features.nft.traits.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun NFTAssetTraitsContent(state: NFTAssetTraitsUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier + .padding(TangemTheme.dimens.spacing16), + title = null, + contentHorizontalPadding = 0.dp, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()), + ) { + state.traits.forEach { trait -> + key(trait.id) { + NFTAssetTrait( + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing8, + ), + state = trait, + ) + } + } + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTDetailsAssetAsset(@PreviewParameter(NFTAssetTraitsProvider::class) state: NFTAssetTraitsUM) { + TangemThemePreview { + NFTAssetTraitsContent( + state = state, + ) + } +} + +private class NFTAssetTraitsProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTAssetTraitsUM( + traits = persistentListOf( + NFTAssetTraitUM( + id = "1", + name = "Trait 1", + value = "Value", + ), + NFTAssetTraitUM( + id = "2", + name = "Trait 2", + value = "Value", + ), + NFTAssetTraitUM( + id = "3", + name = "Trait 3", + value = "Value", + ), + NFTAssetTraitUM( + id = "4", + name = "Trait 4", + value = "Value", + ), + NFTAssetTraitUM( + id = "5", + name = "Trait 5", + value = "Value", + ), + ), + onBackClick = { }, + ), + ), +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt index 79f9406a97..05e2c4fd39 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt @@ -7,14 +7,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import coil.compose.AsyncImage import coil.request.ImageRequest +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.features.onboarding.v2.impl.R @Composable -internal fun WalletCard(url: String?, modifier: Modifier = Modifier) { +internal fun WalletCard(artwork: ArtworkUM?, modifier: Modifier = Modifier) { AsyncImage( modifier = modifier, model = ImageRequest.Builder(LocalContext.current) - .data(url) + .data(artwork?.verifiedArtwork ?: artwork?.defaultUrl) .crossfade(true) .build(), placeholder = painterResource(R.drawable.card_placeholder_black), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index e466f10765..b6a675a820 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.replaceAll import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.models.scan.ProductType import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,7 +13,6 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.common.util.cardTypesResolver -import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager @@ -29,6 +29,7 @@ import com.tangem.features.onboarding.v2.entry.impl.analytics.OnboardingEntryEve import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.NonCancellable @@ -150,10 +151,16 @@ internal class OnboardingEntryModel @Inject constructor( if (askBiometryFeatureToggles.isAskForBiometryEnabled) { modelScope.launch { if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) { + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.BiometricScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks(doneMode)), ) } else { + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.Done( mode = doneMode, @@ -163,6 +170,9 @@ internal class OnboardingEntryModel @Inject constructor( } } } else { + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.Done( mode = doneMode, @@ -177,6 +187,9 @@ internal class OnboardingEntryModel @Inject constructor( ) : AskBiometryComponent.ModelCallbacks { override fun onAllowed() { analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.On)) + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.Done( mode = doneMode, @@ -187,6 +200,9 @@ internal class OnboardingEntryModel @Inject constructor( override fun onDenied() { analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.Off)) + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } stackNavigation.replaceAll( OnboardingRoute.Done( mode = doneMode, @@ -232,6 +248,12 @@ internal class OnboardingEntryModel @Inject constructor( } } + private inline fun doIfVisa(function: () -> Unit) { + if (params.scanResponse.productType == ProductType.Visa) { + function() + } + } + inner class OnboardingTwinModelCallbacks : OnboardingTwinComponent.ModelCallbacks { override fun onDone() { navigateToFinalScreenFlow() diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index 2a35bf160f..c685d24d3c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -6,6 +6,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ProductType import com.tangem.domain.onboarding.repository.OnboardingRepository @@ -16,6 +17,7 @@ import com.tangem.features.onboarding.v2.common.ui.interruptBackupDialog import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMultiWalletUM +import com.tangem.operations.attestation.ArtworkSize import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.BackupServiceHolder import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -78,18 +80,26 @@ internal class OnboardingMultiWalletModel @Inject constructor( modelScope.launch { backups.collectLatest { backup -> when { - _uiState.value.artwork2Url == null && backup.card2 != null -> { + _uiState.value.artwork2 == null && backup.card2 != null -> { val artwork = - getCardImageUseCase.invoke(backup.card2.cardId, backup.card2.cardPublicKey) + getCardImageUseCase.invoke( + cardId = backup.card2.cardId, + cardPublicKey = backup.card2.cardPublicKey, + size = ArtworkSize.LARGE, + ) _uiState.update { - it.copy(artwork2Url = artwork) + it.copy(artwork2 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } - _uiState.value.artwork3Url == null && backup.card3 != null -> { + _uiState.value.artwork3 == null && backup.card3 != null -> { val artwork = - getCardImageUseCase.invoke(backup.card3.cardId, backup.card3.cardPublicKey) + getCardImageUseCase.invoke( + cardId = backup.card3.cardId, + cardPublicKey = backup.card3.cardPublicKey, + size = ArtworkSize.LARGE, + ) _uiState.update { - it.copy(artwork3Url = artwork) + it.copy(artwork3 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } } @@ -156,12 +166,14 @@ internal class OnboardingMultiWalletModel @Inject constructor( private fun loadCardArtwork() { modelScope.launch { val artwork = - getCardImageUseCase.invoke(params.scanResponse.card.cardId, params.scanResponse.card.cardPublicKey) + getCardImageUseCase.invoke( + cardId = params.scanResponse.card.cardId, + cardPublicKey = params.scanResponse.card.cardPublicKey, + size = ArtworkSize.LARGE, + ) - if (artwork != getCardImageUseCase.getDefaultFallbackUrl()) { - _uiState.update { - it.copy(artwork1Url = artwork) - } + _uiState.update { + it.copy(artwork1 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt index 4af97756ab..00b794fd58 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt @@ -25,9 +25,9 @@ internal fun OnboardingMultiWallet( ) { if (isSeedPhraseState.not()) { WalletArtworks( - url1 = state.artwork1Url, - url2 = state.artwork2Url, - url3 = state.artwork3Url, + artwork1 = state.artwork1, + artwork2 = state.artwork2, + artwork3 = state.artwork3, modifier = Modifier .padding(horizontal = 34.dp) .padding(top = 20.dp) @@ -53,7 +53,7 @@ internal fun OnboardingMultiWallet( private fun Preview() { TangemThemePreview { OnboardingMultiWallet( - state = OnboardingMultiWalletUM(artwork1Url = null), + state = OnboardingMultiWalletUM(artwork1 = null), artworksState = WalletArtworksState.Folded, isSeedPhraseState = false, childContent = { md -> @@ -73,7 +73,7 @@ private fun Preview() { private fun Preview2() { TangemThemePreview { OnboardingMultiWallet( - state = OnboardingMultiWalletUM(artwork1Url = null), + state = OnboardingMultiWalletUM(artwork1 = null), artworksState = WalletArtworksState.Folded, isSeedPhraseState = true, childContent = { md -> diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt index cb4b29a905..d199e807dc 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.zIndex +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.common.ui.WalletCard @@ -57,9 +58,9 @@ sealed class WalletArtworksState { @Composable fun WalletArtworks( - url1: String?, - url2: String?, - url3: String?, + artwork1: ArtworkUM?, + artwork2: ArtworkUM?, + artwork3: ArtworkUM?, state: WalletArtworksState, modifier: Modifier = Modifier, ) { @@ -120,9 +121,9 @@ fun WalletArtworks( } AnimatedWalletCards( - url1 = url1, - url2 = url2, - url3 = url3, + artwork1 = artwork1, + artwork2 = artwork2, + artwork3 = artwork3, modifier = Modifier .widthIn(max = 450.dp) .matchParentSize(), @@ -152,9 +153,9 @@ private data class WalletCardTransitionState( @Suppress("LongParameterList") @Composable private fun AnimatedWalletCards( - url1: String?, - url2: String?, - url3: String?, + artwork1: ArtworkUM?, + artwork2: ArtworkUM?, + artwork3: ArtworkUM?, transition1: Transition, transition2: Transition, transition3: Transition, @@ -199,7 +200,7 @@ private fun AnimatedWalletCards( scaleY = scaleY3 rotationZ = rotation3 }, - url = url3, + artwork = artwork3, ) WalletCard( @@ -217,7 +218,7 @@ private fun AnimatedWalletCards( scaleX = scaleX2 scaleY = scaleY2 }, - url = url2, + artwork = artwork2, ) WalletCard( @@ -234,7 +235,7 @@ private fun AnimatedWalletCards( translationY = translationY1 rotationZ = rotation1 }, - url = url1, + artwork = artwork1, ) } } @@ -447,9 +448,9 @@ private fun Preview() { var state: WalletArtworksState by remember { mutableStateOf(WalletArtworksState.Folded) } WalletArtworks( - url1 = null, - url2 = null, - url3 = null, + artwork1 = null, + artwork2 = null, + artwork3 = null, state = state, modifier = Modifier.fillMaxWidth(), ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt index 829828761d..3220607f0f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt @@ -1,7 +1,9 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.ui.state +import com.tangem.core.ui.components.artwork.ArtworkUM + internal data class OnboardingMultiWalletUM( - val artwork1Url: String? = null, - val artwork2Url: String? = null, - val artwork3Url: String? = null, + val artwork1: ArtworkUM? = null, + val artwork2: ArtworkUM? = null, + val artwork3: ArtworkUM? = null, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt index 59f7b2e7ab..ce79faeb51 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.Analytics import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletBuilder @@ -93,9 +94,11 @@ internal class OnboardingNoteCreateWalletModel @Inject constructor( private fun observeArtwork() { modelScope.launch { - params.childParams.commonState.collect { + params.childParams.commonState.collect { state -> _uiState.value = _uiState.value.copy( - artworkUrl = it.cardArtworkUrl, + artwork = state.cardArtwork?.let { + ArtworkUM(it.verifiedArtwork, it.defaultUrl) + }, ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt index f12d83941a..91644d77bb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt @@ -32,9 +32,9 @@ internal fun OnboardingNoteCreateWallet(state: OnboardingNoteCreateWalletUM, mod verticalArrangement = Arrangement.Bottom, ) { WalletArtworks( - url1 = state.artworkUrl, - url2 = null, - url3 = null, + artwork1 = state.artwork, + artwork2 = null, + artwork3 = null, modifier = Modifier .padding(top = 64.dp) .padding(horizontal = 24.dp) @@ -86,7 +86,7 @@ private fun OnboardingNoteCreatePreview() { TangemThemePreview { OnboardingNoteCreateWallet( state = OnboardingNoteCreateWalletUM( - artworkUrl = null, + artwork = null, createWalletInProgress = false, onCreateClick = {}, ), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt index 950445d00d..d11347b973 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt @@ -1,7 +1,9 @@ package com.tangem.features.onboarding.v2.note.impl.child.create.ui.state +import com.tangem.core.ui.components.artwork.ArtworkUM + data class OnboardingNoteCreateWalletUM( - val artworkUrl: String? = null, + val artwork: ArtworkUM? = null, val createWalletInProgress: Boolean = false, val onCreateClick: () -> Unit = {}, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt index 021509cd04..bdac9d90ca 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt @@ -129,7 +129,7 @@ internal class OnboardingNoteTopUpModel @Inject constructor( modelScope.launch { params.childParams.commonState.collect { _uiState.value = _uiState.value.copy( - cardArtworkUrl = it.cardArtworkUrl, + cardArtwork = it.cardArtwork, ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt index 64125adc75..9830905a93 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.SpacerHMax +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalTangemShimmer import com.tangem.core.ui.res.TangemTheme @@ -21,11 +22,12 @@ import com.tangem.features.onboarding.v2.common.ui.RefreshButton import com.tangem.features.onboarding.v2.common.ui.WalletCard import com.tangem.features.onboarding.v2.impl.R import com.valentinilk.shimmer.shimmer +import kotlinx.collections.immutable.ImmutableList @Composable fun OnboardingNoteTopUpHeader( balance: String, - cardArtworkUrl: String?, + cardArtwork: ArtworkUM?, isRefreshing: Boolean, onRefreshBalanceClick: () -> Unit, modifier: Modifier = Modifier, @@ -77,7 +79,7 @@ fun OnboardingNoteTopUpHeader( } WalletCard( modifier = Modifier.width(120.dp).align(Alignment.TopCenter), - url = cardArtworkUrl, + artwork = cardArtwork, ) RefreshButton( modifier = Modifier.align(Alignment.BottomCenter), @@ -93,7 +95,7 @@ private fun OnboardinNoteTopUpHeaderPreview() { TangemThemePreview { OnboardingNoteTopUpHeader( balance = "0.00000001 BTC", - cardArtworkUrl = "", + cardArtwork = ArtworkUM(null as ImmutableList?, ""), onRefreshBalanceClick = {}, isRefreshing = false, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt index b7bee01049..476d06b378 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt @@ -34,7 +34,7 @@ fun OnboardingNoteTopUp(state: OnboardingNoteTopUpUM, modifier: Modifier = Modif ) { OnboardingNoteTopUpHeader( balance = state.balance, - cardArtworkUrl = state.cardArtworkUrl, + cardArtwork = state.cardArtwork, onRefreshBalanceClick = state.onRefreshBalanceClick, isRefreshing = state.isRefreshing, modifier = Modifier diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt index 327de0d586..c9d9ca3541 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt @@ -1,9 +1,10 @@ package com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig data class OnboardingNoteTopUpUM( - val cardArtworkUrl: String? = null, + val cardArtwork: ArtworkUM? = null, val availableForBuy: Boolean = false, val availableForBuyLoading: Boolean = true, val balance: String = "", diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt index 8ebe5bcd8b..740f471257 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.note.impl.model +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet @@ -7,6 +8,6 @@ import com.tangem.domain.wallets.models.UserWallet internal data class OnboardingNoteCommonState( val scanResponse: ScanResponse, val userWallet: UserWallet? = null, - val cardArtworkUrl: String? = null, + val cardArtwork: ArtworkUM? = null, val cryptoCurrencyStatus: CryptoCurrencyStatus? = null, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt index 30733d3ff8..43303a39b2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt @@ -6,6 +6,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.features.onboarding.v2.common.ui.exitOnboardingDialog @@ -13,6 +14,7 @@ import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent import com.tangem.features.onboarding.v2.note.impl.OnboardingNoteInnerNavigationState import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute import com.tangem.features.onboarding.v2.note.impl.route.stepNum +import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -50,7 +52,7 @@ internal class OnboardingNoteModel @Inject constructor( val commonUiState: MutableStateFlow = _uiState init { - loadArtworkUrl() + loadArtwork() } fun updateStepForNewRoute(route: OnboardingNoteRoute) { @@ -85,15 +87,16 @@ internal class OnboardingNoteModel @Inject constructor( } } - private fun loadArtworkUrl() { + private fun loadArtwork() { modelScope.launch { val cardInfo = params.scanResponse.card - val url = getCardImageUseCase( + val artwork = getCardImageUseCase.invoke( cardId = cardInfo.cardId, cardPublicKey = cardInfo.cardPublicKey, + size = ArtworkSize.LARGE, ) _uiState.update { - it.copy(cardArtworkUrl = url) + it.copy(cardArtwork = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt index 640ac88a7f..5bedacb9ee 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt @@ -9,6 +9,7 @@ import com.arkivanov.essenty.instancekeeper.getOrCreateSimple import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.common.TapWorkarounds.isVisa import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType @@ -40,7 +41,13 @@ internal class DefaultOnboardingStepperComponent @AssistedInject constructor( componentScope.launch { val cardInfo = getCardInfoUseCase(params.scanResponse).getOrNull() ?: return@launch - sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) + sendFeedbackEmailUseCase( + if (params.scanResponse.card.isVisa) { + FeedbackEmailType.Visa.Activation(cardInfo) + } else { + FeedbackEmailType.DirectUserRequest(cardInfo) + }, + ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt index 5fba3214f8..97758be4e1 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.util.fastForEach import androidx.compose.ui.zIndex import com.tangem.core.ui.components.SpacerH8 import com.tangem.core.ui.components.SpacerHMax +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -212,7 +213,7 @@ private fun AnimatedTwinCards( scaleX = scaleX2 scaleY = scaleY2 }, - url = Artwork.TWIN_CARD_2_URL, + artwork = ArtworkUM(defaultUrl = Artwork.TWIN_CARD_2_URL), ) WalletCard( @@ -229,7 +230,7 @@ private fun AnimatedTwinCards( translationY = translationY1 rotationZ = rotation1 }, - url = Artwork.TWIN_CARD_1_URL, + artwork = ArtworkUM(defaultUrl = Artwork.TWIN_CARD_1_URL), ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt index b3cc93357a..c18f5585e7 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt @@ -167,7 +167,9 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( params = OnboardingVisaOtherWalletComponent.Params( childParams = childParams, onDone = { - model.stackNavigation.pushNew(OnboardingVisaRoute.PinCode(activationOrderInfo = it)) + model.stackNavigation.pushNew( + OnboardingVisaRoute.PinCode(activationOrderInfo = it, pinCodeValidationError = false), + ) }, ), ) @@ -176,6 +178,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( config = OnboardingVisaPinCodeComponent.Config( scanResponse = model.currentScanResponse.value, activationOrderInfo = route.activationOrderInfo, + wasValidationError = route.pinCodeValidationError, ), params = OnboardingVisaPinCodeComponent.Params( childParams = childParams, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt index 71c5adec28..a07b1acd3a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -19,6 +20,9 @@ import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.domain.visa.repository.VisaAuthRepository import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.ui.state.OnboardingVisaAccessCodeUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.common.ActivationReadyEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.visa.VisaCardActivationTaskMode @@ -30,6 +34,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class OnboardingVisaAccessCodeModel @Inject constructor( @@ -40,6 +45,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( private val tangemSdkManager: TangemSdkManager, private val visaAuthRepository: VisaAuthRepository, private val uiMessageSender: UiMessageSender, + private val analyticsEventsHandler: AnalyticsEventHandler, ) : Model() { private val params: OnboardingVisaAccessCodeComponent.Config = paramsContainer.require() @@ -59,6 +65,10 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( val onBack = MutableSharedFlow() val onDone = MutableSharedFlow() + init { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.SettingAccessCodeStarted) + } + fun onBack() { if (uiState.value.buttonLoading) return @@ -82,6 +92,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( _uiState.update { it.copy( accessCodeFirst = textFieldValue, + accessCodeSecond = TextFieldValue(), atLeastMinCharsError = false, ) } @@ -99,11 +110,14 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( private fun onContinue() { when (uiState.value.step) { OnboardingVisaAccessCodeUM.Step.Enter -> { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.AccessCodeEntered) if (checkAccessCodeMinChars().not()) return _uiState.update { it.copy(step = OnboardingVisaAccessCodeUM.Step.ReEnter) } + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.AccessCodeReenterScreen) } OnboardingVisaAccessCodeUM.Step.ReEnter -> { if (checkAccessCodesMatch().not()) return + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.OnboardingVisa) startActivationProcess(accessCode = uiState.value.accessCodeFirst.text) } } @@ -160,6 +174,9 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( is CompletionResult.Failure -> { loading(false) uiMessageSender.showErrorDialog(result.error.universalError) + analyticsEventsHandler.send( + VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE), + ) return@launch } is CompletionResult.Success -> result.data diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt index a4803e73d0..9f14644a5c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.approve.model import androidx.compose.runtime.Stable import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -15,6 +16,9 @@ import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.features.onboarding.v2.visa.impl.child.approve.OnboardingVisaApproveComponent import com.tangem.features.onboarding.v2.visa.impl.child.approve.ui.state.OnboardingVisaApproveUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableSharedFlow @@ -32,6 +36,7 @@ internal class OnboardingVisaApproveModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val uiMessageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -47,6 +52,10 @@ internal class OnboardingVisaApproveModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.WalletPrepare) + } + private fun getInitialState(): OnboardingVisaApproveUM { return OnboardingVisaApproveUM( onApproveClick = ::onApproveClick, @@ -56,6 +65,8 @@ internal class OnboardingVisaApproveModel @Inject constructor( private fun onApproveClick() { loading(true) + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonApprove) + modelScope.launch { val dataToSign = runCatching { visaActivationRepository.getCustomerWalletAcceptanceData(params.preparationDataForApprove.request) @@ -77,6 +88,9 @@ internal class OnboardingVisaApproveModel @Inject constructor( is CompletionResult.Failure -> { loading(false) uiMessageSender.showErrorDialog(result.error.universalError) + analyticsEventHandler.send( + VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE), + ) return@launch } is CompletionResult.Success -> result.data diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt index 3d48e57718..b9d427dc44 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.ui.extensions.resourceReference @@ -8,6 +9,7 @@ import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.OnboardingVisaChooseWalletComponent.Params.Event import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.ui.state.OnboardingVisaChooseWalletUM import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.ui.state.SelectableChainRowUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableSharedFlow @@ -21,6 +23,7 @@ import javax.inject.Inject @ModelScoped internal class OnboardingVisaChooseWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val _uiState = MutableStateFlow(getInitialState()) @@ -53,7 +56,18 @@ internal class OnboardingVisaChooseWalletModel @Inject constructor( private fun onContinueClicked() { modelScope.launch { val selectedOption = _uiState.value.selectedOption ?: return@launch + val value = getAnalyticsValue(selectedOption.event) + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ChooseWalletScreen(value)) onEvent.emit(selectedOption.event) } } + + private fun getAnalyticsValue(event: Event) = when (event) { + Event.TangemWallet -> { + "Tangem" + } + Event.OtherWallet -> { + "Other" + } + } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt index b8d608e1d3..a138341670 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.inprogress.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -20,6 +21,7 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.models.UserWallet 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.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.route.OnboardingVisaRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.delay @@ -41,6 +43,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( private val userWalletBuilderFactory: UserWalletBuilder.Factory, private val userWalletsListManager: UserWalletsListManager, private val uiMessageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -53,6 +56,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( val onDone = MutableSharedFlow() init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ActivationInProgressScreen) modelScope.launch { while (true) { val result = runCatching { @@ -71,9 +75,10 @@ internal class OnboardingVisaInProgressModel @Inject constructor( VisaActivationRemoteState.WaitingForActivationFinishing, -> { } - is VisaActivationRemoteState.WaitingPinCode -> { - navigateToPinCode(result.activationOrderInfo) - return@launch + is VisaActivationRemoteState.AwaitingPinCode -> { + navigateToPinCodeIfNeeded(result) { + return@launch + } } VisaActivationRemoteState.Activated -> { finishActivation() @@ -86,8 +91,37 @@ internal class OnboardingVisaInProgressModel @Inject constructor( } } - private suspend fun navigateToPinCode(activationOrderInfo: VisaActivationOrderInfo) { - onDone.emit(Params.DoneEvent.NavigateTo(OnboardingVisaRoute.PinCode(activationOrderInfo))) + private suspend inline fun navigateToPinCodeIfNeeded( + remoteState: VisaActivationRemoteState.AwaitingPinCode, + returnBlock: () -> Unit, + ) { + when (remoteState.status) { + VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode -> { + onDone.emit( + Params.DoneEvent.NavigateTo( + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = false, + ), + ), + ) + returnBlock() + } + VisaActivationRemoteState.AwaitingPinCode.Status.WasError -> { + onDone.emit( + Params.DoneEvent.NavigateTo( + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = true, + ), + ), + ) + returnBlock() + } + VisaActivationRemoteState.AwaitingPinCode.Status.InProgress -> { + /** waiting for new state */ + } + } } private suspend fun finishActivation() { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt index 875e5a4b49..fa1adb5d3c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -13,6 +14,7 @@ import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.OnboardingVisaOtherWalletComponent import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.ui.state.OnboardingVisaOtherWalletUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,6 +31,7 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val urlOpener: UrlOpener, private val shareManager: ShareManager, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val config = paramsContainer.require() @@ -44,13 +47,14 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( val onDone = MutableSharedFlow() init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.GoToWebsiteOpened) modelScope.launch { while (true) { val result = runCatching { visaActivationRepository.getActivationRemoteStateLongPoll() }.getOrNull() - if (result is VisaActivationRemoteState.WaitingPinCode) { + if (result is VisaActivationRemoteState.AwaitingPinCode) { onDone.emit(result.activationOrderInfo) break } @@ -68,10 +72,12 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( } private fun onShareClicked() { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonShareLink) shareManager.shareText("https://tangem.com/") // TODO } private fun onOpenInBrowserClicked() { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonBrowser) urlOpener.openUrl("https://tangem.com/") // TODO } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt index b70d2995a1..baf52ede33 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt @@ -48,6 +48,7 @@ internal class OnboardingVisaPinCodeComponent( data class Config( val scanResponse: ScanResponse, val activationOrderInfo: VisaActivationOrderInfo, + val wasValidationError: Boolean, ) data class Params( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt index bcb96ade2f..432a024889 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.pincode.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -17,6 +18,7 @@ import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.onboarding.v2.impl.R 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.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -37,6 +39,7 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( private val authTokenStorage: VisaAuthTokenStorage, private val otpStorage: VisaAuthTokenStorage, private val setVisaPinCodeUseCase: SetVisaPinCodeUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -49,11 +52,20 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.PinCodeScreenOpened) + } + private fun getInitialState(): OnboardingVisaPinCodeUM { return OnboardingVisaPinCodeUM( submitButtonEnabled = false, onPinCodeChange = ::onPinCodeChange, onSubmitClick = ::onSubmitClick, + error = if (params.wasValidationError) { + resourceReference(R.string.visa_onboarding_pin_not_accepted) + } else { + null + }, ) } @@ -66,6 +78,7 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( pinCode = pin, submitButtonEnabled = PinCodeValidation.validate(pin), error = if (isError) { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ErrorPinValidation) resourceReference(R.string.visa_onboarding_pin_validation_error_message) } else { null @@ -76,6 +89,7 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( } private fun onSubmitClick() { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.PinEntered) val pinCode = _uiState.value.pinCode if (PinCodeValidation.validate(pinCode).not()) return diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt index db3f28bd1a..35869da702 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -14,6 +15,9 @@ import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.features.onboarding.v2.visa.impl.child.welcome.OnboardingVisaWelcomeComponent.Config import com.tangem.features.onboarding.v2.visa.impl.child.welcome.OnboardingVisaWelcomeComponent.DoneEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.child.welcome.ui.state.OnboardingVisaWelcomeUM import com.tangem.features.onboarding.v2.visa.impl.common.ActivationReadyEvent import com.tangem.sdk.api.TangemSdkManager @@ -33,6 +37,7 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( private val tangemSdkManager: TangemSdkManager, private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, private val uiMessageSender: UiMessageSender, + private val analyticsEventsHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -42,6 +47,10 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.ActivationScreenOpened) + } + private fun getInitialState(): OnboardingVisaWelcomeUM { return OnboardingVisaWelcomeUM( mode = if (params is Config.WelcomeBack) { @@ -54,6 +63,7 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( } private fun onContinueClick() { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.ButtonActivate) if (params !is Config.WelcomeBack) { modelScope.launch { onDone.emit(DoneEvent.WelcomeDone) } return @@ -86,6 +96,9 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( is CompletionResult.Failure -> { loading(false) uiMessageSender.showErrorDialog(result.error.universalError) + analyticsEventsHandler.send( + VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE), + ) return@launch } is CompletionResult.Success -> result.data diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/OnboardingVisaAnalyticsEvent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/OnboardingVisaAnalyticsEvent.kt new file mode 100644 index 0000000000..25759dce3a --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/OnboardingVisaAnalyticsEvent.kt @@ -0,0 +1,85 @@ +package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +internal sealed class OnboardingVisaAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Onboarding / Visa", event, params) { + + data object ActivationScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Activation Screen Opened", + ) + + data object ButtonActivate : OnboardingVisaAnalyticsEvent( + event = "Button - Activate", + ) + + data object SettingAccessCodeStarted : OnboardingVisaAnalyticsEvent( + event = "Setting Access Code Started", + ) + + data object AccessCodeEntered : OnboardingVisaAnalyticsEvent( + event = "Access Code Entered", + ) + + data object AccessCodeReenterScreen : OnboardingVisaAnalyticsEvent( + event = "Access Code Re-enter Screen", + ) + + data object OnboardingVisa : OnboardingVisaAnalyticsEvent( + event = "Onboarding / Visa", + ) + + data class ChooseWalletScreen( + val type: String, + ) : OnboardingVisaAnalyticsEvent( + event = "Choose Wallet Screen", + params = mapOf("Type" to type), + ) + + data object WalletPrepare : OnboardingVisaAnalyticsEvent( + event = "Wallet Prepare", + ) + + data object ButtonApprove : OnboardingVisaAnalyticsEvent( + event = "Button - Approve", + ) + + data object GoToWebsiteOpened : OnboardingVisaAnalyticsEvent( + event = "Go To Website Opened", + ) + + data object ButtonBrowser : OnboardingVisaAnalyticsEvent( + event = "Button - Browser", + ) + + data object ButtonShareLink : OnboardingVisaAnalyticsEvent( + event = "Button - Share Link", + ) + + data object ActivationInProgressScreen : OnboardingVisaAnalyticsEvent( + event = "Activation In Progress Screen", + ) + + data object PinCodeScreenOpened : OnboardingVisaAnalyticsEvent( + event = "PIN Code Screen Opened", + ) + + data object PinEntered : OnboardingVisaAnalyticsEvent( + event = "PIN Entered", + params = mapOf("Type" to "Visa"), + ) + + data object BiometricScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Biometric Screen Opened", + ) + + data object SuccessScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Success Screen Opened", + ) + + data object ErrorPinValidation : OnboardingVisaAnalyticsEvent( + event = "Error - Pin Validation", + ) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt new file mode 100644 index 0000000000..f31e8c0a83 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +internal const val ONBOARDING_SOURCE = "Onboarding" +internal const val MAIN_SOURCE = "Main" + +internal sealed class VisaAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Visa", event, params) { + + data class Errors( + val errorCode: String, + val source: String, + ) : VisaAnalyticsEvent( + event = "Errors", + params = mapOf( + "Error Code" to errorCode, + "Source" to source, + ), + ) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt index f93540c8ee..8bc8b14032 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt @@ -159,8 +159,11 @@ internal class OnboardingVisaModel @Inject constructor( VisaActivationRemoteState.WaitingForActivationFinishing -> { OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode) } - is VisaActivationRemoteState.WaitingPinCode -> { - OnboardingVisaRoute.PinCode(activationOrderInfo = remoteState.activationOrderInfo) + is VisaActivationRemoteState.AwaitingPinCode -> { + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = false, + ) } VisaActivationRemoteState.Activated, VisaActivationRemoteState.BlockedForActivation, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt index 9dca6484e9..762fc96544 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt @@ -48,5 +48,8 @@ internal sealed class OnboardingVisaRoute { } @Serializable - data class PinCode(val activationOrderInfo: VisaActivationOrderInfo) : OnboardingVisaRoute() + data class PinCode( + val activationOrderInfo: VisaActivationOrderInfo, + val pinCodeValidationError: Boolean, + ) : OnboardingVisaRoute() } \ No newline at end of file diff --git a/features/send-v2/api/build.gradle.kts b/features/send-v2/api/build.gradle.kts index 7bf806b77c..fce40fb61c 100644 --- a/features/send-v2/api/build.gradle.kts +++ b/features/send-v2/api/build.gradle.kts @@ -16,4 +16,5 @@ dependencies { /** Domain models */ implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) + implementation(projects.domain.nft.models) } \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt new file mode 100644 index 0000000000..2403e179e6 --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt @@ -0,0 +1,17 @@ +package com.tangem.features.send.v2.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId + +interface NFTSendComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val nftCollectionName: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts index 6858492736..9629c4825b 100644 --- a/features/send-v2/impl/build.gradle.kts +++ b/features/send-v2/impl/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { /** Api */ implementation(projects.features.sendV2.api) implementation(projects.features.txhistory.api) + implementation(projects.features.nft.api) /** Libs */ implementation(projects.libs.crypto) @@ -54,6 +55,7 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.balanceHiding) + implementation(projects.domain.nft.models) /** Compose libraries */ implementation(deps.compose.foundation) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/SendRoute.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/CommonSendRoute.kt similarity index 58% rename from features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/SendRoute.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/CommonSendRoute.kt index 078366292e..7493c4d073 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/SendRoute.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/CommonSendRoute.kt @@ -1,35 +1,34 @@ -package com.tangem.features.send.v2.send +package com.tangem.features.send.v2.common import com.tangem.core.decompose.navigation.Route import kotlinx.serialization.Serializable -@Serializable -internal sealed class SendRoute : Route { +internal sealed class CommonSendRoute : Route { abstract val isEditMode: Boolean @Serializable - data object Empty : SendRoute() { + data object Empty : CommonSendRoute() { override val isEditMode = false } @Serializable - data object Confirm : SendRoute() { + data object Confirm : CommonSendRoute() { override val isEditMode: Boolean = false } @Serializable data class Destination( override val isEditMode: Boolean, - ) : SendRoute() + ) : CommonSendRoute() @Serializable data class Amount( override val isEditMode: Boolean, - ) : SendRoute() + ) : CommonSendRoute() @Serializable - data class Fee( - override val isEditMode: Boolean = true, - ) : SendRoute() + data object Fee : CommonSendRoute() { + override val isEditMode: Boolean = true + } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/PredefinedValues.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/PredefinedValues.kt new file mode 100644 index 0000000000..adbfd84dfc --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/PredefinedValues.kt @@ -0,0 +1,24 @@ +package com.tangem.features.send.v2.common + +sealed class PredefinedValues { + data object Empty : PredefinedValues() + + sealed class Content : PredefinedValues() { + abstract val amount: String + abstract val address: String + abstract val memo: String? + + data class Deeplink( + override val amount: String, + override val address: String, + override val memo: String?, + val transactionId: String, + ) : Content() + + data class QrCode( + override val amount: String, + override val address: String, + override val memo: String?, + ) : Content() + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt new file mode 100644 index 0000000000..69254405cf --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt @@ -0,0 +1,87 @@ +package com.tangem.features.send.v2.common + +import com.tangem.domain.tokens.FetchPendingTransactionsUseCase +import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter +import com.tangem.utils.coroutines.DelayedWork +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.* + +@Suppress("LongParameterList") +internal class SendBalanceUpdater @AssistedInject constructor( + private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase, + private val updateDelayedNetworkStatusUseCase: UpdateDelayedNetworkStatusUseCase, + private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, + private val txHistoryFeatureToggles: TxHistoryFeatureToggles, + private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, + @DelayedWork private val coroutineScope: CoroutineScope, + @Assisted private val userWallet: UserWallet, + @Assisted private val cryptoCurrency: CryptoCurrency, +) { + fun scheduleUpdates() { + coroutineScope.launch { + listOf( + // we should update network to find pending tx after 1 sec + async { + fetchPendingTransactionsUseCase( + userWalletId = userWallet.walletId, + networks = setOf(cryptoCurrency.network), + ) + }, + // we should update tx history and network for new balances + async { + updateTxHistory() + }, + async { + updateNetworkStatuses() + }, + ).awaitAll() + } + } + + private suspend fun updateNetworkStatuses(delay: Long = BALANCE_UPDATE_DELAY) { + updateDelayedNetworkStatusUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + delayMillis = delay, + refresh = true, + ) + } + + private suspend fun updateTxHistory() { + delay(BALANCE_UPDATE_DELAY) + val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + ) + + txHistoryItemsCountEither.onRight { + if (txHistoryFeatureToggles.isFeatureEnabled) { + txHistoryContentUpdateEmitter.triggerUpdate() + } else { + getTxHistoryItemsUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + refresh = true, + ) + } + } + } + + private companion object { + const val BALANCE_UPDATE_DELAY = 11_000L + } + + @AssistedFactory + interface Factory { + fun create(cryptoCurrency: CryptoCurrency, userWallet: UserWallet): SendBalanceUpdater + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt index 1c2c1800c4..0ce1403ae8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt @@ -1,5 +1,7 @@ package com.tangem.features.send.v2.common +import com.tangem.features.send.v2.common.ui.state.NavigationUM + internal interface SendNavigationModelCallback { fun onNavigationResult(navigationUM: NavigationUM) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt similarity index 81% rename from features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendContent.kt rename to features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt index 8451e8d50d..83ce304653 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.v2.send.ui +package com.tangem.features.send.v2.common.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -13,12 +13,14 @@ import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.v2.send.SendRoute -import com.tangem.features.send.v2.common.NavigationUM -import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.ui.state.NavigationUM @Composable -internal fun SendContent(state: SendUM, stackState: ChildStack) { +internal fun SendContent( + navigationUM: NavigationUM, + stackState: ChildStack, +) { Column( modifier = Modifier.Companion .background(color = TangemTheme.colors.background.tertiary) @@ -27,7 +29,7 @@ internal fun SendContent(state: SendUM, stackState: ChildStack() + private val stackNavigation = StackNavigation() - private val innerRouter = InnerRouter( + private val innerRouter = InnerRouter( stackNavigation = stackNavigation, popCallback = { onChildBack() }, ) private val initialRoute = if (params.amount == null) { - SendRoute.Destination(isEditMode = false) + CommonSendRoute.Destination(isEditMode = false) } else { - SendRoute.Empty + CommonSendRoute.Empty } private val currentRoute = MutableStateFlow(initialRoute) @@ -112,47 +115,74 @@ internal class DefaultSendComponent @AssistedInject constructor( BackHandler(onBack = ::onChildBack) SendContent( - state = state, + navigationUM = state.navigationUM, stackState = stackState, ) } - private fun createChild(route: SendRoute, factoryContext: AppComponentContext) = when (route) { - SendRoute.Empty -> getStubComponent() - is SendRoute.Destination -> getDestinationComponent(factoryContext, route) - is SendRoute.Amount -> getAmountComponent(factoryContext, route) - is SendRoute.Fee -> getFeeComponent(factoryContext) - SendRoute.Confirm -> getConfirmComponent(factoryContext) + private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { + CommonSendRoute.Empty -> getStubComponent() + is CommonSendRoute.Destination -> getDestinationComponent(factoryContext, route) + is CommonSendRoute.Amount -> getAmountComponent(factoryContext, route) + is CommonSendRoute.Fee -> getFeeComponent(factoryContext) + CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) } - private fun getDestinationComponent(factoryContext: AppComponentContext, route: SendRoute) = + private fun getDestinationComponent(factoryContext: AppComponentContext, route: CommonSendRoute) = SendDestinationComponent( appComponentContext = factoryContext, params = SendDestinationComponentParams.DestinationParams( state = model.uiState.value.destinationUM, - currentRoute = currentRoute.filterIsInstance(), + currentRoute = currentRoute.filterIsInstance(), isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, userWalletId = params.userWalletId, cryptoCurrency = params.currency, callback = model, - isEditMode = route.isEditMode, + onBackClick = ::onChildBack, + onNextClick = { + if (route.isEditMode) { + onChildBack() + } else { + innerRouter.push(CommonSendRoute.Amount(isEditMode = false)) + } + }, ), ) - private fun getAmountComponent(factoryContext: AppComponentContext, route: SendRoute) = SendAmountComponent( + private fun getAmountComponent(factoryContext: AppComponentContext, route: CommonSendRoute) = SendAmountComponent( appComponentContext = factoryContext, params = SendAmountComponentParams.AmountParams( state = model.uiState.value.amountUM, - currentRoute = currentRoute.filterIsInstance(), + currentRoute = currentRoute.filterIsInstance(), isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, userWallet = model.userWallet, appCurrency = model.appCurrency, cryptoCurrencyStatus = model.cryptoCurrencyStatus, callback = model, - isEditMode = route.isEditMode, - predefinedAmountValue = model.predefinedAmountValue, + predefinedValues = model.predefinedValues, + onBackClick = { + if (route.isEditMode) { + onChildBack() + } else { + analyticsEventHandler.send( + SendAnalyticEvents.CloseButtonClicked( + source = SendScreenSource.Amount, + isFromSummary = false, + isValid = model.uiState.value.amountUM.isPrimaryButtonEnabled, + ), + ) + router.pop() + } + }, + onNextClick = { + if (route.isEditMode) { + onChildBack() + } else { + innerRouter.push(CommonSendRoute.Confirm) + } + }, ), ) @@ -165,7 +195,7 @@ internal class DefaultSendComponent @AssistedInject constructor( appComponentContext = factoryContext, params = SendFeeComponentParams.FeeParams( state = model.uiState.value.feeUM, - currentRoute = currentRoute.filterIsInstance(), + currentRoute = currentRoute.filterIsInstance(), analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, userWallet = model.userWallet, cryptoCurrencyStatus = model.cryptoCurrencyStatus, @@ -174,6 +204,7 @@ internal class DefaultSendComponent @AssistedInject constructor( sendAmount = sendAmount, destinationAddress = destinationAddress, callback = model, + onNextClick = ::onChildBack, ), ) } else { @@ -187,14 +218,14 @@ internal class DefaultSendComponent @AssistedInject constructor( val predefinedAddress = params.destinationAddress val predefinedValues = if (predefinedAmount != null && predefinedTxId != null && predefinedAddress != null) { - SendConfirmComponent.Params.PredefinedValues.Content( + PredefinedValues.Content.Deeplink( amount = predefinedAmount, address = predefinedAddress, - tag = params.tag, + memo = params.tag, transactionId = predefinedTxId, ) } else { - SendConfirmComponent.Params.PredefinedValues.Empty + PredefinedValues.Empty } return SendConfirmComponent( appComponentContext = factoryContext, @@ -216,7 +247,7 @@ internal class DefaultSendComponent @AssistedInject constructor( private fun getStubComponent() = ComposableContentComponent { } private fun onChildBack() { - val isEmptyRoute = childStack.value.active.configuration == SendRoute.Empty + val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyStack = childStack.value.backStack.isEmpty() val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt index 943e48d31f..8b93d2cb71 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -11,18 +11,20 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel import com.tangem.features.send.v2.send.confirm.ui.SendConfirmContent -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.subcomponents.amount.SendAmountBlockComponent import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent -import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData import com.tangem.utils.extensions.orZero import kotlinx.coroutines.flow.* @@ -38,15 +40,13 @@ internal class SendConfirmComponent( private val destinationBlockComponent = SendDestinationBlockComponent( appComponentContext = child("sendConfirmDestinationBlock"), - params = SendDestinationComponentParams.DestinationBlockParams( + params = DestinationBlockParams( state = model.uiState.value.destinationUM, analyticsCategoryName = params.analyticsCategoryName, userWalletId = params.userWallet.walletId, cryptoCurrency = params.cryptoCurrencyStatus.currency, blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), - isPredefinedValues = params.predefinedValues is Params.PredefinedValues.Content, - predefinedAddressValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.address, - predefinedMemoValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.tag, + predefinedValues = params.predefinedValues, ), onResult = model::onDestinationResult, onClick = model::showEditDestination, @@ -61,8 +61,7 @@ internal class SendConfirmComponent( cryptoCurrencyStatus = params.cryptoCurrencyStatus, appCurrency = params.appCurrency, blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), - isPredefinedValues = params.predefinedValues is Params.PredefinedValues.Content, - predefinedAmountValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.amount, + predefinedValues = params.predefinedValues, ), onResult = model::onAmountResult, onClick = model::showEditAmount, @@ -77,8 +76,8 @@ internal class SendConfirmComponent( cryptoCurrencyStatus = params.cryptoCurrencyStatus, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, appCurrency = params.appCurrency, - sendAmount = model.enteredAmount.orZero(), - destinationAddress = model.enteredDestination.orEmpty(), + sendAmount = model.confirmData.enteredAmount.orZero(), + destinationAddress = model.confirmData.enteredDestination.orEmpty(), blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), ), onResult = model::onFeeResult, @@ -93,13 +92,15 @@ internal class SendConfirmComponent( cryptoCurrencyStatus = params.cryptoCurrencyStatus, feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, appCurrency = params.appCurrency, - destinationAddress = model.enteredDestination.orEmpty(), - memo = model.enteredMemo, - amountValue = model.enteredAmount.orZero(), - reduceAmountBy = model.reduceAmountBy.orZero(), - isIgnoreReduce = model.isIgnoreReduce, - fee = model.fee, - feeError = model.feeError, + notificationData = NotificationData( + destinationAddress = model.confirmData.enteredDestination.orEmpty(), + memo = model.confirmData.enteredMemo, + amountValue = model.confirmData.enteredAmount.orZero(), + reduceAmountBy = model.confirmData.reduceAmountBy.orZero(), + isIgnoreReduce = model.confirmData.isIgnoreReduce, + fee = model.confirmData.fee, + feeError = model.confirmData.feeError, + ), ), ) @@ -140,20 +141,10 @@ internal class SendConfirmComponent( val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val appCurrency: AppCurrency, val callback: ModelCallback, - val currentRoute: Flow, + val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, val predefinedValues: PredefinedValues, - ) { - sealed class PredefinedValues { - data object Empty : PredefinedValues() - data class Content( - val transactionId: String, - val amount: String, - val address: String, - val tag: String?, - ) : PredefinedValues() - } - } + ) interface ModelCallback { fun onResult(sendUM: SendUM) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/ConfirmData.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/ConfirmData.kt new file mode 100644 index 0000000000..e6aa732445 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/ConfirmData.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.v2.send.confirm.model + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.transaction.error.GetFeeError +import java.math.BigDecimal + +data class ConfirmData( + val enteredAmount: BigDecimal?, + val reduceAmountBy: BigDecimal, + val isIgnoreReduce: Boolean, + val enteredDestination: String?, + val enteredMemo: String?, + val fee: Fee?, + val feeError: GetFeeError?, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index daf5df6be6..cbc35be461 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -25,20 +24,17 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase -import com.tangem.domain.tokens.FetchPendingTransactionsUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase -import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.CreateTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase -import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase import com.tangem.domain.utils.convertToSdkAmount -import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.SendBalanceUpdater +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.send.SendRoute import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource import com.tangem.features.send.v2.send.analytics.SendAnalyticHelper @@ -47,7 +43,6 @@ import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmIn import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSendingStateTransformer import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmSentStateTransformer import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmationNotificationsTransformer -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.ButtonsUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM @@ -58,17 +53,13 @@ import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData -import com.tangem.features.txhistory.TxHistoryFeatureToggles -import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.DelayedWork import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.stripZeroPlainString import com.tangem.utils.transformer.update -import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import timber.log.Timber -import java.math.BigDecimal import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -89,21 +80,15 @@ internal class SendConfirmModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, - private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase, - private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, - private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, - private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, private val sendFeeCheckReloadListener: SendFeeCheckReloadListener, - private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, private val notificationsUpdateTrigger: NotificationsUpdateTrigger, private val alertFactory: SendConfirmAlertFactory, private val sendAnalyticHelper: SendAnalyticHelper, - private val txHistoryFeatureToggles: TxHistoryFeatureToggles, - @DelayedWork private val coroutineScope: CoroutineScope, private val urlOpener: UrlOpener, private val shareManager: ShareManager, + sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents { private val params: SendConfirmComponent.Params = paramsContainer.require() @@ -113,6 +98,8 @@ internal class SendConfirmModel @Inject constructor( private val cryptoCurrencyStatus = params.cryptoCurrencyStatus private val cryptoCurrency = cryptoCurrencyStatus.currency + private val sendBalanceUpdater = sendBalanceUpdaterFactory.create(cryptoCurrency, userWallet) + private val _uiState = MutableStateFlow(params.state) val uiState = _uiState.asStateFlow() @@ -125,20 +112,16 @@ internal class SendConfirmModel @Inject constructor( private val feeSelectorUM get() = feeUM?.feeSelectorUM as? FeeSelectorUM.Content - val enteredAmount: BigDecimal? - get() = amountState?.amountTextField?.cryptoAmount?.value - val reduceAmountBy: BigDecimal - get() = amountState?.reduceAmountBy.orZero() - val isIgnoreReduce: Boolean - get() = amountState?.isIgnoreReduce == true - val enteredDestination: String? - get() = destinationUM?.addressTextField?.value - val enteredMemo: String? - get() = destinationUM?.memoTextField?.value - val fee: Fee? - get() = feeSelectorUM?.selectedFee - val feeError: GetFeeError? - get() = (feeUM?.feeSelectorUM as? FeeSelectorUM.Error)?.error + val confirmData: ConfirmData + get() = ConfirmData( + enteredAmount = amountState?.amountTextField?.cryptoAmount?.value, + enteredMemo = destinationUM?.memoTextField?.value, + reduceAmountBy = amountState?.reduceAmountBy.orZero(), + isIgnoreReduce = amountState?.isIgnoreReduce == true, + enteredDestination = destinationUM?.addressTextField?.value, + fee = feeSelectorUM?.selectedFee, + feeError = (feeUM?.feeSelectorUM as? FeeSelectorUM.Error)?.error, + ) private var sendIdleTimer: Long = 0L private var isAmountSubtractAvailable = false @@ -183,7 +166,7 @@ internal class SendConfirmModel @Inject constructor( it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) } analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address)) - router.push(SendRoute.Destination(isEditMode = true)) + router.push(CommonSendRoute.Destination(isEditMode = true)) } } @@ -195,7 +178,7 @@ internal class SendConfirmModel @Inject constructor( it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) } analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount)) - router.push(SendRoute.Amount(isEditMode = true)) + router.push(CommonSendRoute.Amount(isEditMode = true)) } } @@ -207,7 +190,7 @@ internal class SendConfirmModel @Inject constructor( it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) } analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee)) - router.push(SendRoute.Fee()) + router.push(CommonSendRoute.Fee) } } @@ -236,15 +219,15 @@ internal class SendConfirmModel @Inject constructor( override fun onFailedTxEmailClick(errorMessage: String) { val amountValue = amountState?.amountTextField?.cryptoAmount?.value - val feeValue = fee?.amount?.value + val feeValue = confirmData.fee?.amount?.value val receivingAmount = if (amountValue != null && feeValue != null) { checkAndCalculateSubtractedAmount( isAmountSubtractAvailable = isAmountSubtractAvailable, cryptoCurrencyStatus = cryptoCurrencyStatus, - amountValue = enteredAmount.orZero(), + amountValue = confirmData.enteredAmount.orZero(), feeValue = feeValue, - reduceAmountBy = reduceAmountBy, + reduceAmountBy = confirmData.reduceAmountBy, ) } else { null @@ -257,7 +240,7 @@ internal class SendConfirmModel @Inject constructor( errorMessage = errorMessage, blockchainId = cryptoCurrency.network.id.value, derivationPath = cryptoCurrency.network.derivationPath.value, - destinationAddress = enteredDestination.orEmpty(), + destinationAddress = confirmData.enteredDestination.orEmpty(), tokenSymbol = if (amount?.type is AmountType.Token) { amount.currencySymbol } else { @@ -322,7 +305,7 @@ internal class SendConfirmModel @Inject constructor( cryptoCurrencyStatus = cryptoCurrencyStatus, amountValue = amountValue, feeValue = feeValue, - reduceAmountBy = reduceAmountBy.orZero(), + reduceAmountBy = confirmData.reduceAmountBy.orZero(), ) modelScope.launch { @@ -369,7 +352,7 @@ internal class SendConfirmModel @Inject constructor( ifRight = { updateTransactionStatus(txData) addTokenToWalletIfNeeded() - scheduleUpdates() + sendBalanceUpdater.scheduleUpdates() sendAnalyticHelper.sendSuccessAnalytics(cryptoCurrency, uiState.value) }, ) @@ -380,7 +363,7 @@ internal class SendConfirmModel @Inject constructor( val wallets = destinationUM?.wallets ?: return val receivingUserWallet = wallets - .firstOrNull { it.address == enteredDestination } + .firstOrNull { it.address == confirmData.enteredDestination } ?: return val userWalletId = receivingUserWallet.userWalletId ?: return @@ -403,49 +386,6 @@ internal class SendConfirmModel @Inject constructor( _uiState.update(SendConfirmSentStateTransformer(txData, txUrl)) } - private fun scheduleUpdates() { - coroutineScope.launch { - listOf( - // we should update network to find pending tx after 1 sec - async { - fetchPendingTransactionsUseCase(userWallet.walletId, setOf(cryptoCurrency.network)) - }, - // we should update tx history and network for new balance - async { - updateTxHistory() - }, - async { - updateDelayedCurrencyStatusUseCase( - userWalletId = userWallet.walletId, - network = cryptoCurrency.network, - delayMillis = BALANCE_UPDATE_DELAY, - refresh = true, - ) - }, - ).awaitAll() - } - } - - private suspend fun updateTxHistory() { - delay(BALANCE_UPDATE_DELAY) - val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - ) - - txHistoryItemsCountEither.onRight { - if (txHistoryFeatureToggles.isFeatureEnabled) { - txHistoryContentUpdateEmitter.triggerUpdate() - } else { - getTxHistoryItemsUseCase( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - refresh = true, - ) - } - } - } - private fun subscribeOnCheckFeeResultUpdates() { sendFeeCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> if (isFeeResultSuccess) { @@ -462,13 +402,13 @@ internal class SendConfirmModel @Inject constructor( modelScope.launch { notificationsUpdateTrigger.triggerUpdate( data = NotificationData( - destinationAddress = enteredDestination.orEmpty(), - memo = enteredMemo, - amountValue = enteredAmount.orZero(), - reduceAmountBy = reduceAmountBy.orZero(), - isIgnoreReduce = isIgnoreReduce, - fee = fee, - feeError = feeError, + destinationAddress = confirmData.enteredDestination.orEmpty(), + memo = confirmData.enteredMemo, + amountValue = confirmData.enteredAmount.orZero(), + reduceAmountBy = confirmData.reduceAmountBy.orZero(), + isIgnoreReduce = confirmData.isIgnoreReduce, + fee = confirmData.fee, + feeError = confirmData.feeError, ), ) _uiState.update { @@ -554,6 +494,5 @@ internal class SendConfirmModel @Inject constructor( private companion object { const val CHECK_FEE_UPDATE_DELAY = 10_000L - const val BALANCE_UPDATE_DELAY = 11_000L } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt index c165ade1ff..c980c558a7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.v2.send.confirm.model.transformers import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.persistentListOf diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt index e632f7b86f..9a8e44344a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt @@ -1,6 +1,6 @@ package com.tangem.features.send.v2.send.confirm.model.transformers -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.utils.transformer.Transformer diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt index c08c566fc2..2d1b64e6b7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt @@ -1,13 +1,13 @@ package com.tangem.features.send.v2.send.confirm.model.transformers import com.tangem.blockchain.common.TransactionData -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.utils.transformer.Transformer internal class SendConfirmSentStateTransformer( - val txData: TransactionData.Uncompiled, - val txUrl: String, + private val txData: TransactionData.Uncompiled, + private val txUrl: String, ) : Transformer { override fun transform(prevState: SendUM): SendUM { return prevState.copy( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt index 0066a9268d..853e5bcdb5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt @@ -16,7 +16,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.subcomponents.fee.model.checkIfFeeTooHigh import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt index f3a151a432..bcf7d3283a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt @@ -5,20 +5,15 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM @@ -26,12 +21,16 @@ import com.tangem.core.ui.components.Keyboard import com.tangem.core.ui.components.SpacerHMax import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.components.transactions.TransactionDoneTitle -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.tapHelp import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM import com.tangem.features.send.v2.send.ui.state.SendUM import com.tangem.features.send.v2.subcomponents.amount.SendAmountBlockComponent import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent @@ -39,11 +38,8 @@ import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent import com.tangem.features.send.v2.subcomponents.notifications import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.delay -private const val TAP_HELP_KEY = "TAP_HELP_KEY" private const val BLOCKS_KEY = "BLOCKS_KEY" -private const val TAP_HELP_ANIMATION_DELAY = 500L @Suppress("LongParameterList") @Composable @@ -148,45 +144,4 @@ private fun LazyListScope.blocks( feeBlockComponent.Content(modifier = Modifier) } } -} - -private fun LazyListScope.tapHelp(isDisplay: Boolean, modifier: Modifier = Modifier) { - item(key = TAP_HELP_KEY) { - var wrappedIsDisplay by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = isDisplay) { - delay(TAP_HELP_ANIMATION_DELAY) - wrappedIsDisplay = isDisplay - } - - if (wrappedIsDisplay) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .fillMaxWidth() - .animateItem() - .padding(top = TangemTheme.dimens.spacing20), - ) { - val background = TangemTheme.colors.button.secondary - Icon( - painter = painterResource(id = R.drawable.ic_send_hint_shape_12), - tint = TangemTheme.colors.button.secondary, - contentDescription = null, - modifier = Modifier, - ) - Text( - text = stringResourceSafe(id = R.string.send_summary_tap_hint), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - modifier = Modifier - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(background) - .padding( - horizontal = TangemTheme.dimens.spacing14, - vertical = TangemTheme.dimens.spacing12, - ), - ) - } - } - } } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/di/CommonSendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/di/CommonSendModelModule.kt new file mode 100644 index 0000000000..d2201019ff --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/di/CommonSendModelModule.kt @@ -0,0 +1,26 @@ +package com.tangem.features.send.v2.send.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel +import com.tangem.features.send.v2.send.model.SendModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface CommonSendModelModule { + + @Binds + @IntoMap + @ClassKey(SendModel::class) + fun provideSendModel(model: SendModel): Model + + @Binds + @IntoMap + @ClassKey(SendConfirmModel::class) + fun provideSendConfirmModel(model: SendConfirmModel): Model +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index 2e22376c66..a2c5df9f21 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -30,7 +30,10 @@ import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.v2.api.SendComponent import com.tangem.features.send.v2.common.NavigationUM -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.send.confirm.SendConfirmComponent import com.tangem.features.send.v2.send.confirm.model.SendConfirmAlertFactory import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM @@ -92,7 +95,7 @@ internal class SendModel @Inject constructor( var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() var appCurrency: AppCurrency = AppCurrency.Default - var predefinedAmountValue: String? = null + var predefinedValues: PredefinedValues = PredefinedValues.Empty private var balanceHidingJobHolder = JobHolder() @@ -218,7 +221,7 @@ internal class SendModel @Inject constructor( feeCryptoCurrencyStatus = feeCurrencyStatus if (params.amount != null) { - router.replaceAll(SendRoute.Confirm) + router.replaceAll(CommonSendRoute.Confirm) } } @@ -233,10 +236,15 @@ internal class SendModel @Inject constructor( val parsedQrCode = parseQrCodeUseCase(address, cryptoCurrency).getOrNull() // Decompose component can be active or inactive depending on its state and navigation stack // If it is in inactive state use parameter to pass value to amount component - predefinedAmountValue = parsedQrCode?.amount?.parseBigDecimal(cryptoCurrency.decimals) + val amount = parsedQrCode?.amount?.parseBigDecimal(cryptoCurrency.decimals).orEmpty() + predefinedValues = PredefinedValues.Content.QrCode( + amount = amount, + address = parsedQrCode?.address.orEmpty(), + memo = parsedQrCode?.memo, + ) // If it is in active state use flow to update value in amount component modelScope.launch { - predefinedAmountValue?.let { sendAmountUpdateQRTrigger.triggerUpdateAmount(it) } + amount?.let { sendAmountUpdateQRTrigger.triggerUpdateAmount(it) } } } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt index c36bda9430..8359df5247 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt @@ -1,8 +1,8 @@ package com.tangem.features.send.v2.send.ui.state import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.features.send.v2.common.NavigationUM -import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt new file mode 100644 index 0000000000..095cb06ce3 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt @@ -0,0 +1,200 @@ +package com.tangem.features.send.v2.sendnft + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.value.ObserveLifecycleMode +import com.arkivanov.decompose.value.subscribe +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.send.v2.api.NFTSendComponent +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.ui.SendContent +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.confirm.NFTSendConfirmComponent +import com.tangem.features.send.v2.sendnft.model.NFTSendModel +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import java.math.BigDecimal + +internal class DefaultNFTSendComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: NFTSendComponent.Params, + private val nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, +) : NFTSendComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val initialRoute = CommonSendRoute.Empty + private val currentRouteFlow = MutableStateFlow(initialRoute) + + private val model: NFTSendModel = getOrCreateModel(params = params, router = innerRouter) + + private val childStack = childStack( + key = "NFTSendInnerStack", + source = stackNavigation, + serializer = null, + initialConfiguration = initialRoute, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + createChild( + configuration, + childByContext( + componentContext = factoryContext, + router = innerRouter, + ), + ) + }, + ) + + init { + childStack.subscribe( + lifecycle = lifecycle, + mode = ObserveLifecycleMode.CREATE_DESTROY, + ) { stack -> + componentScope.launch { + when (val activeComponent = stack.active.instance) { + is NFTSendConfirmComponent -> if (currentRouteFlow.value.isEditMode) { + // analyticsEventHandler.send(SendAnalyticEvents.ConfirmationScreenOpened) + activeComponent.updateState(model.uiState.value) + } + is SendDestinationComponent -> { + // analyticsEventHandler.send(SendAnalyticEvents.AddressScreenOpened) + activeComponent.updateState(model.uiState.value.destinationUM) + } + is SendFeeComponent -> { + // analyticsEventHandler.send(SendAnalyticEvents.FeeScreenOpened) + } + } + currentRouteFlow.emit(stack.active.configuration) + } + } + } + + @Composable + override fun Content(modifier: Modifier) { + val stackState by childStack.subscribeAsState() + val state by model.uiState.collectAsStateWithLifecycle() + + BackHandler(onBack = ::onChildBack) + SendContent( + navigationUM = state.navigationUM, + stackState = stackState, + ) + } + + private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { + is CommonSendRoute.Destination -> getDestinationComponent(factoryContext, route) + is CommonSendRoute.Fee -> getFeeComponent(factoryContext) + CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) + else -> getStubComponent() + } + + private fun getDestinationComponent(factoryContext: AppComponentContext, route: CommonSendRoute) = + SendDestinationComponent( + appComponentContext = factoryContext, + params = SendDestinationComponentParams.DestinationParams( + state = model.uiState.value.destinationUM, + currentRoute = currentRouteFlow.filterIsInstance(), + isBalanceHidingFlow = model.isBalanceHiddenFlow, + analyticsCategoryName = "", // todo + userWalletId = params.userWalletId, + cryptoCurrency = model.cryptoCurrency, + callback = model, + onBackClick = ::onChildBack, + onNextClick = { + if (route.isEditMode) { + onChildBack() + } else { + innerRouter.push(CommonSendRoute.Confirm) + } + }, + ), + ) + + private fun getFeeComponent(factoryContext: AppComponentContext): ComposableContentComponent { + val state = model.uiState.value + val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value + return if (destinationAddress != null) { + SendFeeComponent( + appComponentContext = factoryContext, + params = SendFeeComponentParams.FeeParams( + state = model.uiState.value.feeUM, + currentRoute = currentRouteFlow.filterIsInstance(), + analyticsCategoryName = "", // todo + userWallet = model.userWallet, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, + appCurrency = model.appCurrency, + sendAmount = BigDecimal.ZERO, + destinationAddress = destinationAddress, + callback = model, + onNextClick = ::onChildBack, + ), + ) + } else { + getStubComponent() + } + } + + private fun getConfirmComponent(factoryContext: AppComponentContext) = NFTSendConfirmComponent( + appComponentContext = factoryContext, + nftDetailsBlockComponentFactory = nftDetailsBlockComponentFactory, + params = NFTSendConfirmComponent.Params( + state = model.uiState.value, + analyticsCategoryName = "", // todo + userWallet = model.userWallet, + nftAsset = params.nftAsset, + nftCollectionName = params.nftCollectionName, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, + appCurrency = model.appCurrency, + callback = model, + currentRoute = currentRouteFlow.filterIsInstance(), + isBalanceHidingFlow = model.isBalanceHiddenFlow, + ), + ) + + private fun getStubComponent() = ComposableContentComponent { } + + private fun onChildBack() { + val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty + val isEmptyStack = childStack.value.backStack.isEmpty() + val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success + + if (isEmptyRoute || isEmptyStack || isSuccess) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : NFTSendComponent.Factory { + override fun create(context: AppComponentContext, params: NFTSendComponent.Params): DefaultNFTSendComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt new file mode 100644 index 0000000000..ead66f202b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/NFTSendConfirmComponent.kt @@ -0,0 +1,147 @@ +package com.tangem.features.send.v2.sendnft.confirm + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.confirm.model.NFTSendConfirmModel +import com.tangem.features.send.v2.sendnft.confirm.ui.NFTSendConfirmContent +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams +import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData +import kotlinx.coroutines.flow.* +import java.math.BigDecimal + +internal class NFTSendConfirmComponent( + appComponentContext: AppComponentContext, + params: Params, + nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: NFTSendConfirmModel = getOrCreateModel(params = params) + + private val blockClickEnableFlow = MutableStateFlow(false) + + private val destinationBlockComponent = + SendDestinationBlockComponent( + appComponentContext = child("NFTSendConfirmDestinationBlock"), + params = DestinationBlockParams( + state = model.uiState.value.destinationUM, + analyticsCategoryName = params.analyticsCategoryName, + userWalletId = params.userWallet.walletId, + cryptoCurrency = params.cryptoCurrencyStatus.currency, + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + predefinedValues = PredefinedValues.Empty, + ), + onResult = model::onDestinationResult, + onClick = model::showEditDestination, + ) + + private val feeBlockComponent = SendFeeBlockComponent( + appComponentContext = child("NFTSendConfirmFeeBlock"), + params = SendFeeComponentParams.FeeBlockParams( + state = model.uiState.value.feeUM, + analyticsCategoryName = params.analyticsCategoryName, + userWallet = params.userWallet, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + appCurrency = params.appCurrency, + sendAmount = BigDecimal.ZERO, + destinationAddress = model.confirmData.enteredDestination.orEmpty(), + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + ), + onResult = model::onFeeResult, + onClick = model::showEditFee, + ) + + private val nftDetailsBlockComponent = nftDetailsBlockComponentFactory.create( + context = child("NFTDetailsBlock"), + params = NFTDetailsBlockComponent.Params( + userWalletId = params.userWallet.walletId, + nftAsset = params.nftAsset, + nftCollectionName = params.nftCollectionName, + ), + ) + + private val notificationsComponent = NotificationsComponent( + appComponentContext = child("NFTSendConfirmNotifications"), + params = NotificationsComponent.Params( + analyticsCategoryName = params.analyticsCategoryName, + userWalletId = params.userWallet.walletId, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + appCurrency = params.appCurrency, + notificationData = NotificationData( + destinationAddress = model.confirmData.enteredDestination.orEmpty(), + memo = model.confirmData.enteredMemo, + amountValue = BigDecimal.ZERO, + reduceAmountBy = BigDecimal.ZERO, + isIgnoreReduce = false, + fee = model.confirmData.fee, + feeError = model.confirmData.feeError, + ), + ), + ) + + init { + model.uiState.onEach { state -> + val confirmUM = state.confirmUM as? ConfirmUM.Content + blockClickEnableFlow.value = confirmUM?.isSending == false + }.launchIn(componentScope) + } + + fun updateState(state: NFTSendUM) { + destinationBlockComponent.updateState(state.destinationUM) + feeBlockComponent.updateState(state.feeUM) + model.updateState(state) + } + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val notificationState by notificationsComponent.state.collectAsStateWithLifecycle() + + NFTSendConfirmContent( + nftSendUM = state, + destinationBlockComponent = destinationBlockComponent, + feeBlockComponent = feeBlockComponent, + nftDetailsBlockComponent = nftDetailsBlockComponent, + notificationsComponent = notificationsComponent, + notificationsUM = notificationState, + ) + } + + data class Params( + val state: NFTSendUM, + val analyticsCategoryName: String, + val userWallet: UserWallet, + val appCurrency: AppCurrency, + val nftAsset: NFTAsset, + val nftCollectionName: String, + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val callback: ModelCallback, + val currentRoute: Flow, + val isBalanceHidingFlow: StateFlow, + ) + + interface ModelCallback { + fun onResult(nftSendUM: NFTSendUM) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/ConfirmData.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/ConfirmData.kt new file mode 100644 index 0000000000..704ce61b5c --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/ConfirmData.kt @@ -0,0 +1,11 @@ +package com.tangem.features.send.v2.sendnft.confirm.model + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.transaction.error.GetFeeError + +data class ConfirmData( + val enteredDestination: String?, + val enteredMemo: String?, + val fee: Fee?, + val feeError: GetFeeError?, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmClickIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmClickIntents.kt new file mode 100644 index 0000000000..292d62c6c0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmClickIntents.kt @@ -0,0 +1,16 @@ +package com.tangem.features.send.v2.sendnft.confirm.model + +internal interface NFTSendConfirmClickIntents { + + fun showEditDestination() + + fun showEditFee() + + fun onSendClick() + + fun onExploreClick() + + fun onShareClick() + + fun onFailedTxEmailClick(errorMessage: String) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt new file mode 100644 index 0000000000..1adf375c6e --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -0,0 +1,337 @@ +package com.tangem.features.send.v2.sendnft.confirm.model + +import android.os.SystemClock +import arrow.core.getOrElse +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase +import com.tangem.domain.settings.NeverShowTapHelpUseCase +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource +import com.tangem.features.send.v2.send.ui.state.ButtonsUM +import com.tangem.features.send.v2.sendnft.confirm.NFTSendConfirmComponent +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmInitialStateTransformer +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmSendingStateTransformer +import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformer +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener +import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LongParameterList") +@ModelScoped +internal class NFTSendConfirmModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val appRouter: AppRouter, + private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase, + private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, + private val notificationsUpdateTrigger: NotificationsUpdateTrigger, + private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, + private val sendFeeCheckReloadListener: SendFeeCheckReloadListener, + private val urlOpener: UrlOpener, + private val shareManager: ShareManager, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model(), NFTSendConfirmClickIntents { + + private val params: NFTSendConfirmComponent.Params = paramsContainer.require() + + private val cryptoCurrencyStatus = params.cryptoCurrencyStatus + + private val _uiState = MutableStateFlow(params.state) + val uiState = _uiState.asStateFlow() + + private val destinationUM + get() = uiState.value.destinationUM as? DestinationUM.Content + private val feeUM + get() = uiState.value.feeUM as? FeeUM.Content + private val feeSelectorUM + get() = feeUM?.feeSelectorUM as? FeeSelectorUM.Content + + val confirmData: ConfirmData + get() = ConfirmData( + enteredDestination = destinationUM?.addressTextField?.value, + enteredMemo = destinationUM?.memoTextField?.value, + fee = feeSelectorUM?.selectedFee, + feeError = (feeUM?.feeSelectorUM as? FeeSelectorUM.Error)?.error, + ) + + private var sendIdleTimer: Long = 0L + + init { + configConfirmNavigation() + subscribeOnNotificationsUpdateTrigger() + subscribeOnCheckFeeResultUpdates() + initialState() + } + + fun updateState(nftSendUM: NFTSendUM) { + _uiState.value = nftSendUM + updateConfirmNotifications() + } + + fun onFeeResult(feeUM: FeeUM) { + sendIdleTimer = SystemClock.elapsedRealtime() + _uiState.update { it.copy(feeUM = feeUM) } + updateConfirmNotifications() + } + + fun onDestinationResult(destinationUM: DestinationUM) { + _uiState.update { it.copy(destinationUM = destinationUM) } + updateConfirmNotifications() + } + + override fun showEditDestination() { + modelScope.launch { + neverShowTapHelpUseCase() + _uiState.update { + val confirmUM = it.confirmUM as? ConfirmUM.Content + it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) + } + // analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address)) + router.push(CommonSendRoute.Destination(isEditMode = true)) + } + } + + override fun showEditFee() { + modelScope.launch { + neverShowTapHelpUseCase() + _uiState.update { + val confirmUM = it.confirmUM as? ConfirmUM.Content + it.copy(confirmUM = confirmUM?.copy(showTapHelp = false) ?: it.confirmUM) + } + // analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee)) + router.push(CommonSendRoute.Fee) + } + } + + override fun onSendClick() { + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = true)) + if (SystemClock.elapsedRealtime() - sendIdleTimer < CHECK_FEE_UPDATE_DELAY) { + verifyAndSendTransaction() + } else { + modelScope.launch { + sendFeeCheckReloadTrigger.triggerCheckUpdate() + } + } + } + + override fun onExploreClick() { + val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success ?: return + // analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked) + urlOpener.openUrl(confirmUM.txUrl) + } + + override fun onShareClick() { + val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success ?: return + // analyticsEventHandler.send(SendAnalyticEvents.ShareButtonClicked) + shareManager.shareText(confirmUM.txUrl) + } + + override fun onFailedTxEmailClick(errorMessage: String) { + // TODO() + } + + private fun initialState() { + val confirmUM = uiState.value.confirmUM + val feeUM = uiState.value.feeUM + + modelScope.launch { + val isShowTapHelp = isSendTapHelpEnabledUseCase().getOrElse { false } + if (confirmUM is ConfirmUM.Empty || feeUM is FeeUM.Empty) { + _uiState.update { + it.copy( + confirmUM = NFTSendConfirmInitialStateTransformer( + isShowTapHelp = isShowTapHelp, + ).transform(uiState.value.confirmUM), + ) + } + updateConfirmNotifications() + } + } + } + + private fun subscribeOnNotificationsUpdateTrigger() { + notificationsUpdateTrigger.hasErrorFlow + .onEach { hasError -> + _uiState.update { + val feeUM = it.feeUM as? FeeUM.Content + val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content + it.copy( + confirmUM = (it.confirmUM as? ConfirmUM.Content)?.copy( + isPrimaryButtonEnabled = !hasError && feeSelectorUM != null, + ) ?: it.confirmUM, + ) + } + } + .launchIn(modelScope) + } + + private fun verifyAndSendTransaction() { + // TODO: + } + + // private suspend fun sendTransaction(txData: TransactionData.Uncompiled) { + // val result = sendTransactionUseCase( + // txData = txData, + // userWallet = userWallet, + // network = cryptoCurrency.network, + // ) + // + // _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = false)) + // + // result.fold( + // ifLeft = { error -> + // // alertFactory.getSendTransactionErrorState( + // // error = error, + // // popBack = appRouter::pop, + // // onFailedTxEmailClick = ::onFailedTxEmailClick, + // // ) + // analyticsEventHandler.send(SendAnalyticEvents.TransactionError(cryptoCurrency.symbol)) + // }, + // ifRight = { + // updateTransactionStatus(txData) + // sendBalanceUpdater.scheduleUpdates() + // // sendAnalyticHelper.sendSuccessAnalytics(cryptoCurrency, uiState.value) + // }, + // ) + // } + + private fun subscribeOnCheckFeeResultUpdates() { + sendFeeCheckReloadListener.checkReloadResultFlow.onEach { isFeeResultSuccess -> + if (isFeeResultSuccess) { + sendIdleTimer = SystemClock.elapsedRealtime() + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = true)) + verifyAndSendTransaction() + } else { + _uiState.update(NFTSendConfirmSendingStateTransformer(isSending = false)) + } + }.launchIn(modelScope) + } + + // private suspend fun updateTransactionStatus(txData: TransactionData.Uncompiled) { + // val txUrl = getExplorerTransactionUrlUseCase( + // userWalletId = userWallet.walletId, + // network = cryptoCurrency.network, + // ).getOrElse { "" } + // _uiState.update(NFTSendConfirmSentStateTransformer(txData, txUrl)) + // } + + private fun updateConfirmNotifications() { + modelScope.launch { + notificationsUpdateTrigger.triggerUpdate( + data = NotificationData( + destinationAddress = confirmData.enteredDestination.orEmpty(), + memo = confirmData.enteredMemo, + amountValue = BigDecimal.ZERO, + reduceAmountBy = BigDecimal.ZERO, + isIgnoreReduce = false, + fee = confirmData.fee, + feeError = confirmData.feeError, + ), + ) + } + _uiState.update { + it.copy( + confirmUM = NFTSendConfirmationNotificationsTransformer( + feeUM = uiState.value.feeUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrencyStatus.currency, + ).transform(uiState.value.confirmUM), + ) + } + } + + private fun configConfirmNavigation() { + combine( + flow = uiState, + flow2 = params.currentRoute, + transform = { state, route -> state to route }, + ).onEach { (state, _) -> + val confirmUM = state.confirmUM + params.callback.onResult( + state.copy( + navigationUM = NavigationUM.Content( + title = resourceReference( + id = R.string.send_summary_title, + formatArgs = wrappedList(params.cryptoCurrencyStatus.currency.name), + ), + subtitle = null, + backIconRes = R.drawable.ic_close_24, + backIconClick = { + analyticsEventHandler.send( + SendAnalyticEvents.CloseButtonClicked( + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = confirmUM.isPrimaryButtonEnabled, + ), + ) + appRouter.pop() + }, + primaryButton = ButtonsUM.PrimaryButtonUM( + text = when (confirmUM) { + is ConfirmUM.Success -> resourceReference(R.string.common_close) + is ConfirmUM.Content -> if (confirmUM.isSending) { + resourceReference(R.string.send_sending) + } else { + resourceReference(R.string.common_send) + } + else -> resourceReference(R.string.common_send) + }, + iconResId = R.drawable.ic_tangem_24.takeIf { confirmUM is ConfirmUM.Content }, + isEnabled = confirmUM.isPrimaryButtonEnabled, + isHapticClick = confirmUM is ConfirmUM.Content && !confirmUM.isSending, + onClick = { + when (confirmUM) { + is ConfirmUM.Success -> appRouter.pop() + is ConfirmUM.Content -> if (confirmUM.isSending) { + return@PrimaryButtonUM + } else { + onSendClick() + } + else -> return@PrimaryButtonUM + } + }, + ), + prevButton = null, + secondaryPairButtonsUM = ButtonsUM.SecondaryPairButtonsUM( + leftText = resourceReference(R.string.common_explore), + leftIconResId = R.drawable.ic_web_24, + onLeftClick = ::onExploreClick, + rightText = resourceReference(R.string.common_share), + rightIconResId = R.drawable.ic_share_24, + onRightClick = ::onShareClick, + ).takeIf { confirmUM is ConfirmUM.Success }, + ), + ), + ) + }.launchIn(modelScope) + } + + private companion object { + const val CHECK_FEE_UPDATE_DELAY = 10_000L + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmSentStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmSentStateTransformer.kt new file mode 100644 index 0000000000..5c613e80e6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmSentStateTransformer.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.sendnft.confirm.model + +import com.tangem.blockchain.common.TransactionData +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.utils.transformer.Transformer + +internal class NFTSendConfirmSentStateTransformer( + private val txData: TransactionData.Uncompiled, + private val txUrl: String, +) : Transformer { + override fun transform(prevState: NFTSendUM): NFTSendUM { + return prevState.copy( + confirmUM = ConfirmUM.Success( + transactionDate = txData.date?.timeInMillis ?: System.currentTimeMillis(), + txUrl = txUrl, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmInitialStateTransformer.kt new file mode 100644 index 0000000000..004117bd1d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmInitialStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.send.v2.sendnft.confirm.model.transformers + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +internal class NFTSendConfirmInitialStateTransformer( + private val isShowTapHelp: Boolean, +) : Transformer { + override fun transform(prevState: ConfirmUM): ConfirmUM { + return ConfirmUM.Content( + isSending = false, + showTapHelp = isShowTapHelp, + sendingFooter = TextReference.EMPTY, + notifications = persistentListOf(), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmSendingStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmSendingStateTransformer.kt new file mode 100644 index 0000000000..7b96c1a703 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmSendingStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.send.v2.sendnft.confirm.model.transformers + +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.utils.transformer.Transformer + +internal class NFTSendConfirmSendingStateTransformer( + val isSending: Boolean, +) : Transformer { + override fun transform(prevState: NFTSendUM): NFTSendUM { + val confirmUM = prevState.confirmUM as? ConfirmUM.Content ?: return prevState + return prevState.copy( + confirmUM = confirmUM.copy( + isPrimaryButtonEnabled = !isSending, + isSending = isSending, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt new file mode 100644 index 0000000000..af93239501 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformer.kt @@ -0,0 +1,55 @@ +package com.tangem.features.send.v2.sendnft.confirm.model.transformers + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.subcomponents.fee.model.checkIfFeeTooHigh +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class NFTSendConfirmationNotificationsTransformer( + private val feeUM: FeeUM, + private val analyticsEventHandler: AnalyticsEventHandler, + private val cryptoCurrency: CryptoCurrency, +) : Transformer { + override fun transform(prevState: ConfirmUM): ConfirmUM { + val state = prevState as? ConfirmUM.Content ?: return prevState + val feeUM = feeUM as? FeeUM.Content ?: return prevState + return state.copy( + notifications = buildList { + addTooHighNotification(feeUM.feeSelectorUM) + addTooLowNotification(feeUM) + }.toPersistentList(), + ) + } + + private fun MutableList.addTooLowNotification(feeUM: FeeUM.Content) { + val feeSelectorUM = feeUM.feeSelectorUM as? FeeSelectorUM.Content ?: return + val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return + val minimumValue = multipleFees.minimum.amount.value ?: return + val customAmount = feeSelectorUM.customValues.firstOrNull() ?: return + val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) + if (feeSelectorUM.selectedType == FeeType.Custom && minimumValue > customValue) { + add(NotificationUM.Warning.FeeTooLow) + analyticsEventHandler.send( + SendAnalyticEvents.NoticeTransactionDelays(cryptoCurrency.symbol), + ) + } + } + + private fun MutableList.addTooHighNotification(feeSelectorUM: FeeSelectorUM) { + if (feeSelectorUM !is FeeSelectorUM.Content) return + + val (isFeeTooHigh, diff) = checkIfFeeTooHigh(feeSelectorUM) + if (isFeeTooHigh) { + add(NotificationUM.Warning.TooHigh(diff)) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/ui/NFTSendConfirmContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/ui/NFTSendConfirmContent.kt new file mode 100644 index 0000000000..735f3ecaf0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/ui/NFTSendConfirmContent.kt @@ -0,0 +1,104 @@ +package com.tangem.features.send.v2.sendnft.confirm.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.transactions.TransactionDoneTitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.tapHelp +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent +import com.tangem.features.send.v2.subcomponents.notifications +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import kotlinx.collections.immutable.ImmutableList + +private const val BLOCKS_KEY = "BLOCKS_KEY" + +@Suppress("LongParameterList") +@Composable +internal fun NFTSendConfirmContent( + nftSendUM: NFTSendUM, + destinationBlockComponent: SendDestinationBlockComponent, + nftDetailsBlockComponent: NFTDetailsBlockComponent, + feeBlockComponent: SendFeeBlockComponent, + notificationsComponent: NotificationsComponent, + notificationsUM: ImmutableList, +) { + val confirmUM = nftSendUM.confirmUM as? ConfirmUM.Content + + Column { + LazyColumn( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) { + blocks( + nftSendUM = nftSendUM, + destinationBlockComponent = destinationBlockComponent, + nftDetailsBlockComponent = nftDetailsBlockComponent, + feeBlockComponent = feeBlockComponent, + ) + if (confirmUM != null) { + tapHelp(isDisplay = confirmUM.showTapHelp) + with(notificationsComponent) { + content( + state = notificationsUM, + isClickDisabled = confirmUM.isSending, + ) + } + notifications( + notifications = confirmUM.notifications, + isClickDisabled = confirmUM.isSending, + ) + } + } + } +} + +private fun LazyListScope.blocks( + nftSendUM: NFTSendUM, + destinationBlockComponent: SendDestinationBlockComponent, + nftDetailsBlockComponent: NFTDetailsBlockComponent, + feeBlockComponent: SendFeeBlockComponent, +) { + item(key = BLOCKS_KEY) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AnimatedVisibility( + visible = nftSendUM.confirmUM is ConfirmUM.Success, + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + ) { + val wrappedConfirmUM = remember(this) { nftSendUM.confirmUM as ConfirmUM.Success } + TransactionDoneTitle( + title = resourceReference(R.string.sent_transaction_sent_title), + subtitle = resourceReference( + R.string.send_date_format, + wrappedList( + wrappedConfirmUM.transactionDate.toTimeFormat(DateTimeFormatters.dateFormatter), + wrappedConfirmUM.transactionDate.toTimeFormat(), + ), + ), + modifier = Modifier.padding(vertical = 12.dp), + ) + } + destinationBlockComponent.Content(modifier = Modifier) + + nftDetailsBlockComponent.Content(modifier = Modifier) + + feeBlockComponent.Content(modifier = Modifier) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt new file mode 100644 index 0000000000..3252c7bc71 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt @@ -0,0 +1,26 @@ +package com.tangem.features.send.v2.sendnft.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.sendnft.confirm.model.NFTSendConfirmModel +import com.tangem.features.send.v2.sendnft.model.NFTSendModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface NFTSendModelModule { + + @Binds + @IntoMap + @ClassKey(NFTSendModel::class) + fun provideNFTSendModel(model: NFTSendModel): Model + + @Binds + @IntoMap + @ClassKey(NFTSendConfirmModel::class) + fun provideNFTSendConfirmModel(model: NFTSendConfirmModel): Model +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt new file mode 100644 index 0000000000..7b3be9e2b3 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -0,0 +1,151 @@ +package com.tangem.features.send.v2.sendnft.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase +import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.send.v2.api.NFTSendComponent +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.confirm.NFTSendConfirmComponent +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.properties.Delegates + +internal interface SendNFTComponentCallback : + SendFeeComponent.ModelCallback, + SendDestinationComponent.ModelCallback, + NFTSendConfirmComponent.ModelCallback + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class NFTSendModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, + private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase, + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, +) : Model(), SendNFTComponentCallback { + + val params: NFTSendComponent.Params = paramsContainer.require() + private val userWalletId = params.userWalletId + private val nftAsset = params.nftAsset + + private val _uiState = MutableStateFlow(initialState()) + val uiState = _uiState.asStateFlow() + + private val _isBalanceHiddenFlow = MutableStateFlow(false) + val isBalanceHiddenFlow = _isBalanceHiddenFlow.asStateFlow() + + var cryptoCurrency: CryptoCurrency by Delegates.notNull() + var userWallet: UserWallet by Delegates.notNull() + var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + var appCurrency: AppCurrency = AppCurrency.Default + + init { + subscribeOnCurrencyStatusUpdates() + initAppCurrency() + } + + override fun onNavigationResult(navigationUM: NavigationUM) { + _uiState.update { it.copy(navigationUM = navigationUM) } + } + + override fun onResult(nftSendUM: NFTSendUM) { + _uiState.value = nftSendUM + } + + override fun onDestinationResult(destinationUM: DestinationUM) { + _uiState.update { it.copy(destinationUM = destinationUM) } + } + + override fun onFeeResult(feeUM: FeeUM) { + _uiState.update { it.copy(feeUM = feeUM) } + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun subscribeOnCurrencyStatusUpdates() { + modelScope.launch { + getUserWalletUseCase(params.userWalletId).fold( + ifRight = { wallet -> + userWallet = wallet + + cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId).getOrNull() + ?.filterIsInstance() + ?.firstOrNull { it.network == nftAsset.network } + ?: return@launch + + getCurrenciesStatusUpdates( + isSingleWalletWithToken = wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + }, + ifLeft = { + // sendConfirmAlertFactory.getGenericErrorState(::onFailedTxEmailClick) + return@launch + }, + ) + } + } + + private fun getCurrenciesStatusUpdates(isSingleWalletWithToken: Boolean) { + getCurrencyStatusUpdatesUseCase( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + isSingleWalletWithTokens = isSingleWalletWithToken, + ).onEach { maybeCryptoCurrency -> + maybeCryptoCurrency.fold( + ifRight = { cryptoStatus -> + cryptoCurrencyStatus = cryptoStatus + feeCryptoCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoStatus, + ).getOrNull() ?: cryptoStatus + + router.replaceAll(CommonSendRoute.Destination(isEditMode = false)) + }, + ifLeft = { + // sendConfirmAlertFactory.getGenericErrorState { + // onFailedTxEmailClick(it.toString()) + // } + }, + ) + }.launchIn(modelScope) + } + + private fun initialState(): NFTSendUM = NFTSendUM( + destinationUM = DestinationUM.Empty(), + feeUM = FeeUM.Empty(), + confirmUM = ConfirmUM.Empty, + navigationUM = NavigationUM.Empty, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt new file mode 100644 index 0000000000..526bfdd621 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.send.v2.sendnft.ui.state + +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM + +internal data class NFTSendUM( + val destinationUM: DestinationUM, + val feeUM: FeeUM, + val confirmUM: ConfirmUM, + val navigationUM: NavigationUM, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt index b616b506f7..b05e56c031 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt @@ -9,6 +9,7 @@ import com.tangem.common.ui.amountScreen.ui.AmountBlock import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams.AmountBlockParams import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel import kotlinx.coroutines.flow.launchIn @@ -39,7 +40,7 @@ internal class SendAmountBlockComponent( AmountBlock( amountState = state, isClickDisabled = !isClickEnabled, - isEditingDisabled = params.isPredefinedValues, + isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, onClick = onClick, ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt index 0df4012c9d..7a59f9cfde 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt @@ -4,7 +4,8 @@ import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent.ModelCallback import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -16,7 +17,7 @@ internal sealed class SendAmountComponentParams { abstract val userWallet: UserWallet abstract val appCurrency: AppCurrency abstract val cryptoCurrencyStatus: CryptoCurrencyStatus - abstract val predefinedAmountValue: String? + abstract val predefinedValues: PredefinedValues data class AmountParams( override val state: AmountState, @@ -24,11 +25,12 @@ internal sealed class SendAmountComponentParams { override val userWallet: UserWallet, override val appCurrency: AppCurrency, override val cryptoCurrencyStatus: CryptoCurrencyStatus, - override val predefinedAmountValue: String?, - val isEditMode: Boolean, + override val predefinedValues: PredefinedValues, val callback: ModelCallback, - val currentRoute: Flow, + val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, + val onBackClick: () -> Unit, + val onNextClick: () -> Unit, ) : SendAmountComponentParams() data class AmountBlockParams( @@ -37,8 +39,7 @@ internal sealed class SendAmountComponentParams { override val userWallet: UserWallet, override val appCurrency: AppCurrency, override val cryptoCurrencyStatus: CryptoCurrencyStatus, - override val predefinedAmountValue: String?, + override val predefinedValues: PredefinedValues, val blockClickEnableFlow: StateFlow, - val isPredefinedValues: Boolean, ) : SendAmountComponentParams() } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index 3c137c1bb4..11974ef160 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -1,7 +1,6 @@ package com.tangem.features.send.v2.subcomponents.amount.model import androidx.compose.runtime.Stable -import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.amountScreen.converters.* import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer @@ -13,17 +12,14 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase -import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.send.SendRoute -import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents -import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource import com.tangem.features.send.v2.send.ui.state.ButtonsUM import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceListener @@ -46,8 +42,6 @@ import javax.inject.Inject internal class SendAmountModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, - private val appRouter: AppRouter, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, private val sendAmountReduceListener: SendAmountReduceListener, private val feeReloadTrigger: SendFeeReloadTrigger, @@ -109,7 +103,10 @@ internal class SendAmountModel @Inject constructor( ) } } - params.predefinedAmountValue?.let(::onAmountValueChange) + val predefinedValues = params.predefinedValues as? PredefinedValues.Content + if (predefinedValues?.amount != null) { + onAmountValueChange(predefinedValues.amount) + } } fun updateState(amountUM: AmountState) { @@ -167,11 +164,6 @@ internal class SendAmountModel @Inject constructor( ) } saveResult() - if ((params as? SendAmountComponentParams.AmountParams)?.isEditMode == true) { - router.pop() - } else { - router.push(SendRoute.Confirm) - } } private fun subscribeOnAmountReduceToTriggerUpdates() { @@ -247,20 +239,7 @@ internal class SendAmountModel @Inject constructor( } else { R.drawable.ic_close_24 }, - backIconClick = { - if (route.isEditMode) { - router.pop() - } else { - analyticsEventHandler.send( - SendAnalyticEvents.CloseButtonClicked( - source = SendScreenSource.Amount, - isFromSummary = false, - isValid = state.isPrimaryButtonEnabled, - ), - ) - appRouter.pop() - } - }, + backIconClick = { params.onBackClick() }, primaryButton = ButtonsUM.PrimaryButtonUM( text = if (route.isEditMode) { resourceReference(R.string.common_continue) @@ -268,7 +247,10 @@ internal class SendAmountModel @Inject constructor( resourceReference(R.string.common_next) }, isEnabled = state.isPrimaryButtonEnabled, - onClick = ::onAmountNext, + onClick = { + onAmountNext() + params.onNextClick() + }, ), prevButton = ButtonsUM.PrimaryButtonUM( text = TextReference.EMPTY, @@ -276,7 +258,7 @@ internal class SendAmountModel @Inject constructor( isEnabled = true, onClick = { saveResult() - router.pop() + params.onBackClick() }, ).takeIf { route.isEditMode.not() }, secondaryPairButtonsUM = null, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt index ff98000eed..5f500027fa 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel import com.tangem.features.send.v2.subcomponents.destination.ui.DestinationBlock import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM @@ -38,7 +39,7 @@ internal class SendDestinationBlockComponent( DestinationBlock( destinationUM = state, isClickDisabled = !isClickEnabled, - isEditingDisabled = params.isPredefinedValues, + isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, onClick = onClick, ) } diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt index f4a69acb24..ecd05eba6d 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt @@ -2,7 +2,8 @@ package com.tangem.features.send.v2.subcomponents.destination import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute +import com.tangem.features.send.v2.common.PredefinedValues import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent.ModelCallback import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM import kotlinx.coroutines.flow.Flow @@ -21,9 +22,10 @@ internal sealed class SendDestinationComponentParams { override val cryptoCurrency: CryptoCurrency, override val userWalletId: UserWalletId, val isBalanceHidingFlow: StateFlow, - val currentRoute: Flow, + val currentRoute: Flow, val callback: ModelCallback, - val isEditMode: Boolean, + val onBackClick: () -> Unit, + val onNextClick: () -> Unit, ) : SendDestinationComponentParams() data class DestinationBlockParams( @@ -32,8 +34,6 @@ internal sealed class SendDestinationComponentParams { override val userWalletId: UserWalletId, override val cryptoCurrency: CryptoCurrency, val blockClickEnableFlow: StateFlow, - val predefinedAddressValue: String?, - val predefinedMemoValue: String?, - val isPredefinedValues: Boolean, + val predefinedValues: PredefinedValues, ) : SendDestinationComponentParams() } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 510030607b..e9ff9401a2 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -3,7 +3,6 @@ package com.tangem.features.send.v2.subcomponents.destination.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -22,13 +21,14 @@ import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R -import com.tangem.features.send.v2.send.SendRoute import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource import com.tangem.features.send.v2.send.ui.state.ButtonsUM import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource import com.tangem.features.send.v2.subcomponents.destination.analytics.SendDestinationAnalyticEvents import com.tangem.features.send.v2.subcomponents.destination.model.transformers.* @@ -53,7 +53,6 @@ internal class SendDestinationModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, - private val appRouter: AppRouter, private val validateWalletAddressUseCase: ValidateWalletAddressUseCase, private val validateWalletMemoUseCase: ValidateWalletMemoUseCase, private val getWalletsUseCase: GetWalletsUseCase, @@ -85,19 +84,20 @@ internal class SendDestinationModel @Inject constructor( } private fun initialState() { - if ((uiState.value as? DestinationUM.Content)?.isInitialized == false) { + if ((uiState.value as? DestinationUM.Content)?.isInitialized == false || uiState.value is DestinationUM.Empty) { _uiState.update( SendDestinationInitialStateTransformer( cryptoCurrency = cryptoCurrency, isInitialized = true, ), ) - val params = params as? SendDestinationComponentParams.DestinationBlockParams - if (params?.predefinedAddressValue != null) { + val params = params as? DestinationBlockParams + val predefinedValues = params?.predefinedValues as? PredefinedValues.Content.Deeplink + if (predefinedValues?.address != null) { _uiState.update( SendDestinationPredefinedStateTransformer( - address = params.predefinedAddressValue, - memo = params.predefinedMemoValue, + address = predefinedValues.address, + memo = predefinedValues.memo, ), ) } @@ -269,7 +269,8 @@ internal class SendDestinationModel @Inject constructor( private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean, isValidMemo: Boolean) { val isRecent = type == EnterAddressSource.RecentAddress - if (isRecent && isValidAddress && isValidMemo) onNextClick() + if (isRecent && isValidAddress && isValidMemo) saveResult() + (params as? SendDestinationComponentParams.DestinationParams)?.onNextClick?.invoke() } private fun saveResult() { @@ -277,15 +278,6 @@ internal class SendDestinationModel @Inject constructor( params.callback.onDestinationResult(uiState.value) } - private fun onNextClick() { - saveResult() - if ((params as? SendDestinationComponentParams.DestinationParams)?.isEditMode == true) { - router.pop() - } else { - router.push(SendRoute.Amount(isEditMode = false)) - } - } - private fun configDestinationNavigation() { val params = params as? SendDestinationComponentParams.DestinationParams ?: return combine( @@ -303,9 +295,7 @@ internal class SendDestinationModel @Inject constructor( R.drawable.ic_close_24 }, backIconClick = { - if (route.isEditMode) { - router.pop() - } else { + if (!route.isEditMode) { analyticsEventHandler.send( SendAnalyticEvents.CloseButtonClicked( source = SendScreenSource.Address, @@ -313,8 +303,8 @@ internal class SendDestinationModel @Inject constructor( isValid = state.isPrimaryButtonEnabled, ), ) - appRouter.pop() } + params.onBackClick() }, additionalIconRes = R.drawable.ic_qrcode_scan_24, additionalIconClick = ::onQrCodeScanClick, @@ -325,7 +315,10 @@ internal class SendDestinationModel @Inject constructor( resourceReference(R.string.common_next) }, isEnabled = state.isPrimaryButtonEnabled, - onClick = ::onNextClick, + onClick = { + saveResult() + params.onNextClick() + }, ), prevButton = null, secondaryPairButtonsUM = null, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt index 5b7cdce04a..bfb3330873 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt @@ -13,7 +13,7 @@ import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM internal class SendFeeComponent( appComponentContext: AppComponentContext, - private val params: SendFeeComponentParams.FeeParams, + params: SendFeeComponentParams.FeeParams, ) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: SendFeeModel = getOrCreateModel(params = params) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt index 82c9f88081..20e3c94ded 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt @@ -3,7 +3,7 @@ package com.tangem.features.send.v2.subcomponents.fee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.wallets.models.UserWallet -import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.common.CommonSendRoute import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -29,8 +29,9 @@ internal sealed class SendFeeComponentParams { override val appCurrency: AppCurrency, override val sendAmount: BigDecimal, override val destinationAddress: String, - val currentRoute: Flow, + val currentRoute: Flow, val callback: SendFeeComponent.ModelCallback, + val onNextClick: () -> Unit, ) : SendFeeComponentParams() data class FeeBlockParams( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt index 6f2014fce0..b2bd054e2a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt @@ -6,12 +6,11 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.GetTransferFeeUseCase import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase -import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.send.ui.state.ButtonsUM import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadListener @@ -40,9 +39,8 @@ import javax.inject.Inject internal class SendFeeModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - private val router: Router, private val isFeeApproximateUseCase: IsFeeApproximateUseCase, - private val getFeeUseCase: GetFeeUseCase, + private val getTransferFeeUseCase: GetTransferFeeUseCase, private val urlOpener: UrlOpener, private val analyticsEventHandler: AnalyticsEventHandler, private val sendFeeAlertFactory: SendFeeAlertFactory, @@ -140,10 +138,10 @@ internal class SendFeeModel @Inject constructor( ), ) - navigate() + saveResult() } } else { - navigate() + saveResult() } } @@ -194,7 +192,7 @@ internal class SendFeeModel @Inject constructor( if (isShowLoading) { _uiState.update(SendFeeLoadingTransformer) } - getFeeUseCase.invoke( + getTransferFeeUseCase.invoke( amount = amountValue, destination = destinationAddress, userWallet = params.userWallet, @@ -224,7 +222,7 @@ internal class SendFeeModel @Inject constructor( private fun checkLoadFee() { modelScope.launch { - getFeeUseCase.invoke( + getTransferFeeUseCase.invoke( amount = params.sendAmount, destination = params.destinationAddress, userWallet = params.userWallet, @@ -283,11 +281,6 @@ internal class SendFeeModel @Inject constructor( ) } - private fun navigate() { - saveResult() - router.pop() - } - private fun configFeeNavigation() { val params = params as? SendFeeComponentParams.FeeParams ?: return combine( @@ -300,11 +293,14 @@ internal class SendFeeModel @Inject constructor( title = resourceReference(R.string.common_fee_selector_title), subtitle = null, backIconRes = R.drawable.ic_back_24, - backIconClick = router::pop, + backIconClick = params.onNextClick, primaryButton = ButtonsUM.PrimaryButtonUM( text = resourceReference(R.string.common_continue), isEnabled = state.isPrimaryButtonEnabled, - onClick = ::onNextClick, + onClick = { + onNextClick() + params.onNextClick() + }, ), prevButton = null, secondaryPairButtonsUM = null, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt index 14760575ab..4f163c1a61 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt @@ -2,19 +2,17 @@ package com.tangem.features.send.v2.subcomponents.notifications import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.Modifier -import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.send.v2.subcomponents.notifications +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationsModel import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.StateFlow -import java.math.BigDecimal internal class NotificationsComponent( appComponentContext: AppComponentContext, @@ -45,12 +43,6 @@ internal class NotificationsComponent( val cryptoCurrencyStatus: CryptoCurrencyStatus, val feeCryptoCurrencyStatus: CryptoCurrencyStatus, val appCurrency: AppCurrency, - val destinationAddress: String, - val memo: String?, - val amountValue: BigDecimal, - val reduceAmountBy: BigDecimal, - val isIgnoreReduce: Boolean, - val fee: Fee?, - val feeError: GetFeeError?, + val notificationData: NotificationData, ) } \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index 6775e4a9b6..127a253df0 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -79,13 +79,7 @@ internal class NotificationsModel @Inject constructor( private val currency = cryptoCurrencyStatus.currency private val appCurrency = params.appCurrency - private var destinationAddress = params.destinationAddress - private var memo = params.memo - private var amountValue = params.amountValue - private var reduceAmountBy = params.reduceAmountBy - private var isIgnoreReduce = params.isIgnoreReduce - private var fee = params.fee - private var feeError = params.feeError + private var notificationData = params.notificationData private val _uiState = MutableStateFlow>(persistentListOf()) val uiState = _uiState.asStateFlow() @@ -111,14 +105,7 @@ internal class NotificationsModel @Inject constructor( } private suspend fun updateState(data: NotificationData) { - destinationAddress = data.destinationAddress - memo = data.memo - amountValue = data.amountValue - reduceAmountBy = data.reduceAmountBy - isIgnoreReduce = data.isIgnoreReduce - fee = data.fee - feeError = data.feeError - + notificationData = data buildNotifications() } @@ -127,26 +114,20 @@ internal class NotificationsModel @Inject constructor( addFeeUnreachableNotification( tokenStatus = cryptoCurrencyStatus, coinStatus = feeCryptoCurrencyStatus, - feeError = feeError, + feeError = notificationData.feeError, onReload = { modelScope.launch { sendFeeReloadTrigger.triggerUpdate( feeData = SendFeeData( - amount = amountValue, - destinationAddress = destinationAddress, + amount = notificationData.amountValue, + destinationAddress = notificationData.destinationAddress, ), ) } }, onClick = ::showTokenDetails, ) - addDomainNotifications( - destinationAddress = destinationAddress, - memo = memo, - amountValue = amountValue, - reduceAmountBy = reduceAmountBy, - fee = fee, - ) + addDomainNotifications() } notificationsUpdateTrigger.callbackHasError(notifications.any { it is NotificationUM.Error }) @@ -167,13 +148,7 @@ internal class NotificationsModel @Inject constructor( } } - private suspend fun MutableList.addDomainNotifications( - destinationAddress: String, - memo: String?, - amountValue: BigDecimal, - reduceAmountBy: BigDecimal, - fee: Fee?, - ) { + private suspend fun MutableList.addDomainNotifications() = with(notificationData) { val balance = cryptoCurrencyStatus.value.amount ?: return val feeValue = fee?.amount?.value ?: return val isFeeCoverage = checkFeeCoverage( @@ -311,7 +286,7 @@ internal class NotificationsModel @Inject constructor( ) { val validationError = validateTransactionUseCase( userWalletId = userWalletId, - amount = amountValue.convertToSdkAmount(cryptoCurrencyStatus.currency), + amount = enteredAmount.convertToSdkAmount(currency), fee = fee, memo = memo, destination = destinationAddress, @@ -359,7 +334,7 @@ internal class NotificationsModel @Inject constructor( addHighFeeWarningNotification( enteredAmountValue = enteredAmount, cryptoCurrencyStatus = cryptoCurrencyStatus, - ignoreAmountReduce = isIgnoreReduce, + ignoreAmountReduce = notificationData.isIgnoreReduce, onReduceClick = { reduceBy, reduceByDiff, _ -> modelScope.launch { sendAmountReduceTrigger.triggerReduceBy( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt index 9f4e696e39..fbcd15cc1f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/model/SendModel.kt @@ -92,9 +92,9 @@ internal class SendModel @Inject constructor( private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase, - private val getFeeUseCase: GetFeeUseCase, + private val getTransferFeeUseCase: GetTransferFeeUseCase, private val sendTransactionUseCase: SendTransactionUseCase, - private val createTransactionUseCase: CreateTransactionUseCase, + private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val validateWalletAddressUseCase: ValidateWalletAddressUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, @@ -826,7 +826,7 @@ internal class SendModel @Inject constructor( val recipientState = uiState.value.getRecipientState(isFromConfirmation) ?: return null val amount = amountState.amountTextField.cryptoAmount.value ?: return null - return getFeeUseCase.invoke( + return getTransferFeeUseCase.invoke( amount = amount, destination = recipientState.addressTextField.value, userWallet = userWallet, @@ -929,7 +929,7 @@ internal class SendModel @Inject constructor( ) modelScope.launch { - createTransactionUseCase( + createTransferTransactionUseCase( amount = receivingAmount.convertToSdkAmount(cryptoCurrency), fee = fee, memo = memo, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 63648d1762..d122cd48bf 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -302,12 +302,13 @@ internal class StakingModel @Inject constructor( ) modelScope.launch { feeLoader.getFee( - onStakingFee = { gasEstimate -> + onStakingFee = { gasEstimate, isFeeApproximate -> stateController.update( SetConfirmationStateAssentTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = gasEstimate, + isFeeApproximate = isFeeApproximate, cryptoCurrencyStatus = cryptoCurrencyStatus, ), ) @@ -363,13 +364,14 @@ internal class StakingModel @Inject constructor( stakingEventFactory.createSendTransactionErrorAlert(error) stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) }, - onFeeIncreased = { increasedFee -> + onFeeIncreased = { increasedFee, isFeeApproximate -> stateController.updateAll( SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), SetConfirmationStateAssentTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = increasedFee, + isFeeApproximate = isFeeApproximate, cryptoCurrencyStatus = cryptoCurrencyStatus, ), ) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt index dc8db5482a..da6dc0fcff 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/BalanceItemConverter.kt @@ -2,22 +2,28 @@ package com.tangem.features.staking.impl.presentation.state.converters import com.tangem.core.ui.extensions.* 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.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.BalanceItem import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.domain.staking.model.stakekit.BalanceType.Companion.isClickable import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.action.StakingActionType +import com.tangem.domain.staking.utils.getRewardStakingBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.BalanceState +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.toPersistentList import org.joda.time.DateTime +import java.math.BigDecimal import java.util.Calendar internal class BalanceItemConverter( @@ -33,8 +39,7 @@ internal class BalanceItemConverter( val validator = yield.validators.firstOrNull { value.validatorAddress?.contains(it.address, ignoreCase = true) == true } - - val cryptoAmount = value.amount + val cryptoAmount = value.getBalanceValue() val fiatAmount = cryptoCurrencyStatus.value.fiatRate?.times(cryptoAmount) val title = value.type.getTitle(validator?.name) @@ -52,20 +57,41 @@ internal class BalanceItemConverter( ), fiatAmount = fiatAmount, formattedFiatAmount = stringReference( - BigDecimalFormatter.formatFiatAmount( - fiatAmount = fiatAmount, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ), + fiatAmount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, ), rawCurrencyId = value.rawCurrencyId, pendingActions = value.pendingActions.toPersistentList(), - isClickable = value.type.isClickable() && !value.isPending, + isClickable = value.isClickable(), isPending = value.isPending, ) } } + private fun BalanceItem.getBalanceValue(): BigDecimal { + val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance( + blockchainId = cryptoCurrencyStatus.currency.network.id.value, + ) + val yieldBalance = cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data + return if (isIncludeStakingTotalBalance) { + amount + } else { + amount - yieldBalance?.getRewardStakingBalance().orZero() + } + } + + private fun BalanceItem.isClickable(): Boolean { + val networkId = cryptoCurrencyStatus.currency.network.id.value + return when { + isTon(networkId) -> pendingActions.any { it.type == StakingActionType.WITHDRAW } + else -> this.type.isClickable() && !this.isPending + } + } + private fun BalanceType.getTitle(validatorName: String?) = when (this) { BalanceType.PREPARING, BalanceType.STAKED, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt index 1688f31bdf..8043052fe7 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt @@ -18,6 +18,7 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.staking.getCurrentToken import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.staking.impl.presentation.state.StakingStateController import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -38,13 +39,14 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( private val stateController: StakingStateController, private val getFeeUseCase: GetFeeUseCase, private val estimateGasUseCase: EstimateGasUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, @Assisted private val yield: Yield, ) { suspend fun getFee( - onStakingFee: (Fee) -> Unit, + onStakingFee: (Fee, Boolean) -> Unit, onStakingFeeError: (StakingError) -> Unit, onApprovalFee: (TransactionFee) -> Unit, onFeeError: (GetFeeError) -> Unit, @@ -91,7 +93,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( amount: BigDecimal, validatorAddress: String, onStakingFeeError: (StakingError) -> Unit, - onStakingFee: (Fee) -> Unit, + onStakingFee: (Fee, Boolean) -> Unit, ) { val sourceAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value ?: error("No available address") @@ -144,14 +146,14 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( } } + val amount = Amount( + currencySymbol = gasEstimate.token.symbol, + value = gasEstimate.amount, + decimals = gasEstimate.token.decimals, + ) onStakingFee( - Fee.Common( - Amount( - currencySymbol = gasEstimate.token.symbol, - value = gasEstimate.amount, - decimals = gasEstimate.token.decimals, - ), - ), + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt index 27d87225e0..aa0afb181b 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt @@ -5,7 +5,10 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionSender import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.domain.staking.* +import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase +import com.tangem.domain.staking.GetStakingTransactionsUseCase +import com.tangem.domain.staking.SaveUnsubmittedHashUseCase +import com.tangem.domain.staking.SubmitHashUseCase import com.tangem.domain.staking.model.SubmitHashData import com.tangem.domain.staking.model.stakekit.NetworkType import com.tangem.domain.staking.model.stakekit.PendingAction @@ -19,6 +22,7 @@ import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionTy import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.staking.getCurrentToken import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.utils.convertToSdkAmount @@ -49,6 +53,7 @@ internal class StakingTransactionSender @AssistedInject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val submitHashUseCase: SubmitHashUseCase, private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, @Assisted private val yield: Yield, @@ -63,7 +68,7 @@ internal class StakingTransactionSender @AssistedInject constructor( onConstructError: (StakingError) -> Unit, onSendSuccess: (String) -> Unit, onSendError: (SendTransactionError?) -> Unit, - onFeeIncreased: (Fee) -> Unit, + onFeeIncreased: (Fee, Boolean) -> Unit, ) { val state = stateController.value @@ -101,10 +106,10 @@ internal class StakingTransactionSender @AssistedInject constructor( onSendError = onSendError, ) } else { + val amount = fee.amount.copy(value = totalFee) onFeeIncreased( - Fee.Common( - fee.amount.copy(value = totalFee), - ), + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), ) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt index 56e01d3a83..c9a8054cc5 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt @@ -13,6 +13,7 @@ internal class SetConfirmationStateAssentTransformer( private val appCurrencyProvider: Provider, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, private val fee: Fee, + private val isFeeApproximate: Boolean, private val cryptoCurrencyStatus: CryptoCurrencyStatus, ) : Transformer { @@ -31,7 +32,7 @@ internal class SetConfirmationStateAssentTransformer( rate = feeCryptoCurrencyStatus?.value?.fiatRate, isFeeConvertibleToFiat = isFeeConvertibleToFiat, appCurrency = appCurrencyProvider(), - isFeeApproximate = false, + isFeeApproximate = isFeeApproximate, ), isPrimaryButtonEnabled = with(cryptoCurrencyStatus.value) { sources.yieldBalanceSource.isActual() && sources.networkSource.isActual() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt index 510266948e..9ddc12135c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt @@ -5,8 +5,10 @@ import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.COSMOS_SCHEDULE import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.SOLANA_SCHEDULE +import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.TON_SCHEDULE import com.tangem.lib.crypto.BlockchainUtils.isCosmos import com.tangem.lib.crypto.BlockchainUtils.isSolana +import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.StringsSigns.MINUS import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE @@ -14,6 +16,7 @@ import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE private data object StakingRewardSchedule { val COSMOS_SCHEDULE = 5 to 12 val SOLANA_SCHEDULE = 2 to 3 + val TON_SCHEDULE = 1 to 2 } internal fun getRewardScheduleText( @@ -71,6 +74,15 @@ private fun getCustomRewardSchedule(networkId: String, decapitalize: Boolean = f ) } isTron(networkId) -> resourceReference(id = R.string.staking_reward_schedule_day, decapitalize = decapitalize) + isTon(networkId) -> combinedReference( + resourceReference(id = R.string.staking_reward_schedule_each_plural, decapitalize = decapitalize), + stringReference(NON_BREAKING_SPACE.toString()), + stringReference("${TON_SCHEDULE.first}$MINUS${TON_SCHEDULE.second}$NON_BREAKING_SPACE"), + pluralReference( + id = R.plurals.common_days_no_param, + count = TON_SCHEDULE.second, + ), + ) else -> null } } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index 8e465b9c2b..f872cd9c2c 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -344,47 +344,6 @@ internal class DefaultSwapRepository( ) } - override suspend fun getApproveData( - userWalletId: UserWalletId, - networkId: String, - derivationPath: String?, - currency: CryptoCurrency, - amount: BigDecimal?, - spenderAddress: String, - ): String { - val blockchain = - requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } - val walletManager = walletManagersFacade.getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = blockchain, - derivationPath = derivationPath, - ) - - return (walletManager as? Approver)?.getApproveData( - spenderAddress, - amount?.let { convertToAmount(it, currency) }, - ) ?: error("Cannot cast to Approver") - } - - private fun convertToAmount(amount: BigDecimal, currency: CryptoCurrency): Amount { - return Amount( - currencySymbol = currency.symbol, - value = amount, - decimals = currency.decimals, - type = if (currency is CryptoCurrency.Token) { - AmountType.Token( - Token( - symbol = currency.symbol, - contractAddress = currency.contractAddress, - decimals = currency.decimals, - ), - ) - } else { - AmountType.Coin - }, - ) - } - override fun getNativeTokenForNetwork(networkId: String): CryptoCurrency { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } diff --git a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index 46ebd1d768..bacf1b8df5 100644 --- a/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/api/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -41,17 +41,6 @@ interface SwapRepository { spenderAddress: String, ): BigDecimal - @Suppress("LongParameterList") - @Throws(IllegalStateException::class) - suspend fun getApproveData( - userWalletId: UserWalletId, - networkId: String, - derivationPath: String?, - currency: CryptoCurrency, - amount: BigDecimal?, - spenderAddress: String, - ): String - @Suppress("LongParameterList") suspend fun getExchangeData( fromContractAddress: String, diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 14278f36c8..7d52289e23 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -1,5 +1,3 @@ -import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants - plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) @@ -23,22 +21,24 @@ dependencies { /** Domain */ implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.card) + implementation(projects.domain.demo) + implementation(projects.domain.legacy) implementation(projects.domain.models) + implementation(projects.domain.quotes) + implementation(projects.domain.staking) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) + implementation(projects.domain.transaction) + implementation(projects.domain.transaction.models) + implementation(projects.domain.txhistory.models) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) - implementation(projects.domain.transaction) - implementation(projects.domain.legacy) - implementation(projects.libs.blockchainSdk) - implementation(projects.domain.demo) - implementation(projects.domain.card) - implementation(projects.domain.appCurrency.models) - implementation(projects.domain.txhistory.models) - implementation(projects.domain.transaction.models) + implementation(projects.features.swap.domain.api) implementation(projects.features.swap.domain.models) - implementation(projects.domain.staking) + implementation(projects.libs.blockchainSdk) /** Core modules */ implementation(projects.core.utils) diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index a4336b8eae..f6aa62d8cd 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -82,7 +82,6 @@ data class TokenSwapInfo( data class RequestApproveStateData( val fee: TxFeeState, - val approveData: String, val fromTokenAmount: SwapAmount, val spenderAddress: String, ) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 29e7ee6c0d..d5b93c4276 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -7,23 +7,28 @@ import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain.* import com.tangem.blockchain.common.TransactionExtras +import com.tangem.blockchain.common.smartcontract.CompiledSmartContractCallData +import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.common.extensions.hexToBytes +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.quotes.QuotesRepositoryV2 import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.* import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.domain.transaction.models.TransactionType import com.tangem.domain.transaction.usecase.* import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWallet @@ -59,9 +64,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val createTransactionUseCase: CreateTransactionUseCase, + private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, + private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val quotesRepository: QuotesRepository, + private val quotesRepositoryV2: QuotesRepositoryV2, + private val tokensFeatureToggles: TokensFeatureToggles, private val swapTransactionRepository: SwapTransactionRepository, private val currencyChecksRepository: CurrencyChecksRepository, private val appCurrencyRepository: AppCurrencyRepository, @@ -198,32 +207,20 @@ internal class SwapInteractorImpl @AssistedInject constructor( networkId: String, permissionOptions: PermissionOptions, ): SwapTransactionState { - val derivationPath = permissionOptions.fromToken.network.derivationPath.value - val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) { - getApproveData( - networkId = networkId, - derivationPath = derivationPath, - fromToken = permissionOptions.fromToken, - spenderAddress = permissionOptions.spenderAddress, - ) - } else { - permissionOptions.approveData.approveData + val amount = permissionOptions.approveData.fromTokenAmount.takeIf { + permissionOptions.approveType == SwapApproveType.LIMITED } - val approveTransaction = createTransactionUseCase( - amount = BigDecimal.ZERO.convertToSdkAmount(permissionOptions.fromToken), + + val approveTransaction = createApprovalTransactionUseCase( fee = getFeeForTransaction( fee = permissionOptions.txFee, blockchain = Blockchain.fromId(permissionOptions.fromToken.network.id.value), ), - memo = null, - destination = getTokenAddress(permissionOptions.fromToken), - network = permissionOptions.fromToken.network, userWalletId = userWalletId, - txExtras = createDexTxExtras( - dataToSign, - permissionOptions.fromToken.network, - permissionOptions.txFee.gasLimit, - ), + cryptoCurrency = permissionOptions.fromToken as CryptoCurrency.Token, + amount = amount?.value, + contractAddress = permissionOptions.forTokenContractAddress, + spenderAddress = permissionOptions.spenderAddress, ).getOrElse { Timber.e(it, "Failed to create approveTransaction") return SwapTransactionState.Error.UnknownError @@ -609,7 +606,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( userWalletId = userWalletId, network = currencyToSendStatus.currency.network, txExtras = createDexTxExtras(dataToSign, currencyToSendStatus.currency.network, fee.gasLimit), - hash = dataToSign, ).getOrElse { Timber.e(it, "Failed to create swap dex tx data") return SwapTransactionState.Error.UnknownError @@ -665,7 +661,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( return createTransactionExtrasUseCase( data = data, network = network, - transactionType = TransactionType.APPROVE, gasLimit = gasLimit?.toBigInteger(), ).getOrNull() ?: error("failed to create extras") } @@ -701,7 +696,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isDemoCardUseCase(cardId)) return SwapTransactionState.Error.UnknownError - val txData = createTransactionUseCase( + val txData = createTransferTransactionUseCase( amount = amount.value.convertToSdkAmount(currencyToSend.currency), fee = getFeeForTransaction( fee = txFee, @@ -1083,11 +1078,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun createEmptyAmountState(): SwapState { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() return SwapState.EmptyAmountState( - zeroAmountEquivalent = BigDecimalFormatter.formatFiatAmount( - fiatAmount = BigDecimal.ZERO, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ), + zeroAmountEquivalent = BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, ) } @@ -1369,11 +1365,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( val rates = getQuotes(feeCurrencyId) return rates[feeCurrencyId]?.fiatRate?.let { rate -> fees.map { fee -> - BigDecimalFormatter.formatFiatAmount( - fiatAmount = rate.multiply(fee), - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) + rate.multiply(fee).format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } } }.orEmpty() } @@ -1509,7 +1506,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyToSend = swapCurrencyConverter.convert(fromToken), destinationAddress = transaction.txTo, increaseBy = INCREASE_GAS_LIMIT_BY, - data = transaction.txData, + callData = CompiledSmartContractCallData(transaction.txData.hexToBytes()), derivationPath = fromToken.network.derivationPath.value, ) } catch (e: IllegalStateException) { @@ -1613,12 +1610,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val derivationPath = fromToken.network.derivationPath.value // setting up amount for approve with given amount for swap [SwapApproveType.Limited] - val transactionData = getApproveData( - networkId = networkId, - derivationPath = derivationPath, - fromToken = fromToken, - swapAmount = swapAmount, + val callData = SmartContractCallDataProviderFactory.getApprovalCallData( spenderAddress = requireNotNull(spenderAddress) { "Spender address is null" }, + amount = swapAmount.value.convertToSdkAmount(fromToken), + blockchain = Blockchain.fromId(fromToken.network.id.value), ) val cardId = userWallet.scanResponse.card.cardId val feeData = if (isDemoCardUseCase(cardId)) { @@ -1631,7 +1626,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)), destinationAddress = fromToken.getContractAddress(), increaseBy = INCREASE_GAS_LIMIT_BY, - data = transactionData, + callData = callData, derivationPath = derivationPath, ) } catch (e: Exception) { @@ -1668,7 +1663,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( spenderAddress = getTokenAddress(fromToken), requestApproveData = RequestApproveStateData( fee = feeState, - approveData = transactionData, fromTokenAmount = swapAmount, spenderAddress = spenderAddress, ), @@ -2136,23 +2130,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( return PriceImpact.Value(value) } - private suspend fun getApproveData( - networkId: String, - derivationPath: String?, - fromToken: CryptoCurrency, - swapAmount: SwapAmount? = null, - spenderAddress: String, - ): String { - return repository.getApproveData( - userWalletId = userWalletId, - networkId = networkId, - derivationPath = derivationPath, - currency = fromToken, - amount = swapAmount?.value, - spenderAddress = spenderAddress, - ) - } - private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map { val set = ids.mapNotNull { it.rawCurrencyId } .toSet() @@ -2166,7 +2143,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun Set.getQuotesOrEmpty(refresh: Boolean): Set { return try { - quotesRepository.getQuotesSync(this, refresh) + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = this).orEmpty() + } else { + quotesRepository.getQuotesSync(this, refresh) + } } catch (t: Throwable) { emptySet() } diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index e725d7833a..b80dda2c34 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -86,6 +86,7 @@ dependencies { implementation(projects.domain.onramp.models) implementation(projects.domain.promo) implementation(projects.domain.promo.models) + implementation(projects.domain.quotes) /** Temp dependency to swap domain */ implementation(projects.features.swap.domain) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index f9d9aed682..6890405a1d 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -5,7 +5,9 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.swaptx.ExpressAnalyticsStatus import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.quotes.QuotesRepositoryV2 import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Quote import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent @@ -15,10 +17,10 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.utils.Provider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -35,6 +37,8 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val swapTransactionRepository: SwapTransactionRepository, private val swapRepository: SwapRepository, private val quotesRepository: QuotesRepository, + private val quotesRepositoryV2: QuotesRepositoryV2, + private val tokensFeatureToggles: TokensFeatureToggles, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, @@ -198,7 +202,12 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private suspend fun Set.getQuotesOrEmpty(refresh: Boolean): Set { return try { val rawIds = mapNotNull { it.rawCurrencyId }.toSet() - quotesRepository.getQuotesSync(rawIds, refresh) + + if (tokensFeatureToggles.isQuotesLoadingRefactoringEnabled) { + quotesRepositoryV2.getMultiQuoteSyncOrNull(currenciesIds = rawIds).orEmpty() + } else { + quotesRepository.getQuotesSync(rawIds, refresh) + } } catch (t: Throwable) { emptySet() } diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index da4e888dfe..b2a3af1b04 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { implementation(projects.domain.onramp.models) implementation(projects.domain.promo) implementation(projects.domain.promo.models) + implementation(projects.domain.quotes) implementation(projects.domain.settings) implementation(projects.domain.staking) implementation(projects.domain.staking.models) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt index 8fe27fb45f..9aed66d0f3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt @@ -1,9 +1,16 @@ package com.tangem.feature.wallet.child.wallet.model.intents import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.visa.GetVisaCurrencyUseCase import com.tangem.domain.visa.GetVisaTxDetailsUseCase +import com.tangem.domain.visa.model.VisaTxDetails +import com.tangem.domain.wallets.usecase.GetWalletsUseCase 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 @@ -20,22 +27,30 @@ internal interface VisaWalletIntents { fun onVisaTransactionClick(id: String) fun onExploreClick(exploreUrl: String) + + fun onDisputeClick(txDetails: VisaTxDetails) } +@Suppress("LongParameterList") @ModelScoped internal class VisaWalletIntentsImplementor @Inject constructor( private val stateController: WalletStateController, private val eventSender: WalletEventSender, private val getVisaCurrencyUseCase: GetVisaCurrencyUseCase, private val getVisaTxDetailsUseCase: GetVisaTxDetailsUseCase, + private val getCardInfoUseCase: GetCardInfoUseCase, + private val getUserWalletsUseCase: GetWalletsUseCase, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, ) : BaseWalletClickIntents(), VisaWalletIntents { private val balancesAndLimitsBottomSheetConverter by lazy(mode = LazyThreadSafetyMode.NONE) { - BalancesAndLimitsBottomSheetConverter(eventSender) + BalancesAndLimitsBottomSheetConverter(eventSender, analyticsEventHandler) } override fun onBalancesAndLimitsClick() { + analyticsEventHandler.send(MainScreenAnalyticsEvent.LimitsClicked) modelScope.launch(dispatchers.main) { val userWalletId = stateController.getSelectedWalletId() val balancesAndLimits = getVisaCurrencyUseCase(userWalletId) @@ -76,6 +91,23 @@ internal class VisaWalletIntentsImplementor @Inject constructor( } override fun onExploreClick(exploreUrl: String) { + analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonExplore) router.openUrl(exploreUrl) } + + override fun onDisputeClick(txDetails: VisaTxDetails) { + modelScope.launch { + val userWalletId = stateController.getSelectedWalletId() + val userWallet = getUserWalletsUseCase.invokeSync() + .firstOrNull { it.walletId == userWalletId } ?: return@launch + val cardInfo = getCardInfoUseCase.invoke(userWallet.scanResponse).getOrNull() ?: return@launch + + sendFeedbackEmailUseCase( + FeedbackEmailType.Visa.Dispute( + cardInfo = cardInfo, + visaTxDetails = txDetails, + ), + ) + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 79fd4f1a7f..680e373b9b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -2,7 +2,9 @@ package com.tangem.feature.wallet.child.wallet.model.intents import arrow.core.getOrElse import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.nft.analytics.NFTAnalyticsEvent import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.settings.ShouldShowMarketsTooltipUseCase import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase @@ -75,6 +77,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val reduxStateHolder: ReduxStateHolder, private val walletEventSender: WalletEventSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : BaseWalletClickIntents(), WalletContentClickIntents { override fun onDetailsClick() { @@ -240,6 +243,19 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } override fun onNFTClick(userWalletId: UserWalletId) { - router.openNFTCollectionsScreen(userWalletId) + val selectedWallet = stateHolder.getSelectedWallet() as? WalletState.MultiCurrency.Content ?: return + val analyticsState = when (val nftState = selectedWallet.nftState) { + is WalletNFTItemUM.Content -> NFTAnalyticsEvent.NFTListScreenOpened.State.Full(nftState.assetsCount) + is WalletNFTItemUM.Empty -> NFTAnalyticsEvent.NFTListScreenOpened.State.Empty + is WalletNFTItemUM.Failed, + is WalletNFTItemUM.Hidden, + is WalletNFTItemUM.Loading, + -> null + } + analyticsState?.let { + analyticsEventHandler.send(NFTAnalyticsEvent.NFTListScreenOpened(analyticsState)) + } + + router.openNFT(userWalletId) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index 9068f9a7f1..be96aa27a4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -15,6 +15,7 @@ import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetCo import com.tangem.common.ui.bottomsheet.receive.AddressModel import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels +import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.WrappedList @@ -78,7 +79,7 @@ interface WalletCurrencyActionsClickIntents { unavailabilityReason: ScenarioUnavailabilityReason, ) - fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent? = null) fun onStakeClick(cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) @@ -94,7 +95,7 @@ interface WalletCurrencyActionsClickIntents { fun onAnalyticsClick(cryptoCurrencyStatus: CryptoCurrencyStatus) - fun onMultiWalletBuyClick(userWalletId: UserWalletId) + fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) fun onMultiWalletSellClick(userWalletId: UserWalletId) @@ -151,7 +152,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( appRouter.push(route) } - override fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent?) { val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send( @@ -166,6 +167,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( event = TokenReceiveAnalyticsEvent.ReceiveScreenOpened(cryptoCurrencyStatus.currency.symbol), ) + event?.let { analyticsEventHandler.send(it) } + stateHolder.showBottomSheet( createReceiveBottomSheetContent( currency = cryptoCurrencyStatus.currency, @@ -470,7 +473,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } - override fun onMultiWalletBuyClick(userWalletId: UserWalletId) { + override fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) { onMultiWalletActionClick( statusFlow = if (onrampFeatureToggles.isFeatureEnabled) { rampStateManager.getExpressInitializationStatus(userWalletId) @@ -478,7 +481,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( rampStateManager.getBuyInitializationStatus() }, route = AppRoute.BuyCrypto(userWalletId = userWalletId), - eventCreator = MainScreenAnalyticsEvent::ButtonBuy, + eventCreator = { MainScreenAnalyticsEvent.ButtonBuy(status = it, screenType = screenType) }, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index f6305b826f..23ac155b8b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -116,7 +116,7 @@ internal class DefaultWalletRouter @Inject constructor( reduxStateHolder.dispatchDialogShow(StateDialog.ScanFailsDialog(StateDialog.ScanFailsSource.MAIN, onTryAgain)) } - override fun openNFTCollectionsScreen(userWalletId: UserWalletId) { - router.push(AppRoute.NFTCollections(userWalletId)) + override fun openNFT(userWalletId: UserWalletId) { + router.push(AppRoute.NFT(userWalletId)) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 4e49fb9f70..ad25778b8a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -58,5 +58,5 @@ internal interface InnerWalletRouter { fun openScanFailedDialog(onTryAgain: () -> Unit) /** Open NFT collections screen */ - fun openNFTCollectionsScreen(userWalletId: UserWalletId) + fun openNFT(userWalletId: UserWalletId) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt index bf64d76838..a43a0ef835 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt @@ -6,6 +6,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class VisaTxDetailsBottomSheetConfig( val transaction: Transaction, val requests: ImmutableList, + val onDisputeClick: () -> Unit, ) : TangemBottomSheetConfigContent { data class Transaction( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt index 1d65f129ae..1dc3f95565 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt @@ -2,6 +2,8 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import arrow.core.Either import arrow.core.getOrElse +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent.Companion.VISA_TYPE import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat @@ -144,17 +146,23 @@ internal class SetVisaInfoTransformer( ), ) - return return persistentListOf( + return persistentListOf( WalletManageButton.Receive( enabled = true, dimContent = false, - onClick = { clickIntents.onReceiveClick(cryptoCurrencyStatus = cryptoCurrencyStatus) }, - onLongClick = { clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus = cryptoCurrencyStatus) }, + onClick = { + clickIntents.onReceiveClick(cryptoCurrencyStatus, MainScreenAnalyticsEvent.ButtonReceive) + }, + onLongClick = { + clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus) + }, ), WalletManageButton.Buy( enabled = true, dimContent = false, - onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId) }, + onClick = { + clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId, screenType = VISA_TYPE) + }, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt index ffb4722637..eb2e240d23 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt @@ -1,5 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.DateTimeFormatters @@ -13,6 +15,7 @@ import java.math.BigDecimal internal class BalancesAndLimitsBottomSheetConverter( private val eventSender: WalletEventSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Converter { override fun convert(value: VisaCurrency): BalancesAndLimitsBottomSheetConfig { @@ -28,23 +31,25 @@ internal class BalancesAndLimitsBottomSheetConverter( blockedBalance = value.balances.blocked.let(::formatAmount), debit = value.balances.debt.let(::formatAmount), amlVerified = value.balances.verified.let(::formatAmount), - onInfoClick = this::showBalanceInfo, + onInfoClick = this::balanceInfoOnClick, ), limit = BalancesAndLimitsBottomSheetConfig.Limit( availableBy = DateTimeFormatters.formatDate(date = value.limits.expirationDate), total = otpLimit, other = noOtpLimit, singleTransaction = value.limits.singleTransaction.let(::formatAmount), - onInfoClick = { showLimitInfo(otpLimit, noOtpLimit) }, + onInfoClick = { limitInfoOnClick(otpLimit, noOtpLimit) }, ), ) } - private fun showBalanceInfo() { + private fun balanceInfoOnClick() { + analyticsEventHandler.send(MainScreenAnalyticsEvent.NoticeBalancesInfo) eventSender.send(WalletEvent.ShowAlert(WalletAlertState.VisaBalancesInfo)) } - private fun showLimitInfo(totalLimit: String, otherLimit: String) { + private fun limitInfoOnClick(totalLimit: String, otherLimit: String) { + analyticsEventHandler.send(MainScreenAnalyticsEvent.NoticeLimitsInfo) eventSender.send(WalletEvent.ShowAlert(WalletAlertState.VisaLimitsInfo(totalLimit, otherLimit))) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt index ba80bbf6a3..99b787897e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt @@ -24,6 +24,7 @@ internal class VisaTxDetailsBottomSheetConverter( return VisaTxDetailsBottomSheetConfig( transaction = createTransaction(value), requests = value.requests.map(::createRequest).toImmutableList(), + onDisputeClick = { clickIntents.onDisputeClick(value) }, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt index 439993d834..4cb5daf3c1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.utils +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent.Companion.WALLET_TYPE 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 @@ -103,7 +104,7 @@ internal class WalletLoadingStateFactory( WalletManageButton.Buy( enabled = true, dimContent = false, - onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId) }, + onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId, WALLET_TYPE) }, ), WalletManageButton.Swap( enabled = true, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/VisaTxDetailsBottomSheet.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/VisaTxDetailsBottomSheet.kt index 2f1df0fb3e..4007c48a5b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/VisaTxDetailsBottomSheet.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/VisaTxDetailsBottomSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +16,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.SecondaryButtonIconStart import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -36,26 +38,45 @@ internal fun VisaTxDetailsBottomSheet(config: TangemBottomSheetConfig) { } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun VisaTxDetailsBottomSheetContent(config: VisaTxDetailsBottomSheetConfig, modifier: Modifier = Modifier) { - ContentContainer( - modifier = modifier, - blocksCount = config.requests.size.inc(), - title = { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = TangemTheme.dimens.size44) + .background(TangemTheme.colors.background.secondary), + contentAlignment = Alignment.Center, + ) { Text( text = stringResourceSafe(R.string.visa_transaction_details_header), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, ) - }, - block = { index -> - if (index == 0) { + } + + LazyColumn( + modifier = modifier.background(TangemTheme.colors.background.secondary), + contentPadding = PaddingValues( + bottom = TangemTheme.dimens.spacing16, + ), + verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { TransactionBlock(config.transaction) - } else { - BlockchainRequestBlock(config.requests[index - 1]) } - }, - ) + + items(config.requests) { item -> + BlockchainRequestBlock(item) + } + + item { + DisputeButton(config.onDisputeClick) + } + } + } } @Composable @@ -173,38 +194,14 @@ private fun BlockchainRequestBlock(request: VisaTxDetailsBottomSheetConfig.Reque ) } -@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContentContainer( - blocksCount: Int, - title: @Composable BoxScope.() -> Unit, - block: @Composable ColumnScope.(Int) -> Unit, - modifier: Modifier = Modifier, -) { - LazyColumn( - modifier = modifier.background(TangemTheme.colors.background.secondary), - contentPadding = PaddingValues( - bottom = TangemTheme.dimens.spacing16, - ), - verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - stickyHeader { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size44) - .background(TangemTheme.colors.background.secondary), - contentAlignment = Alignment.Center, - content = title, - ) - } - items(blocksCount) { index -> - Column { - block(index) - } - } - } +private fun DisputeButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + SecondaryButtonIconStart( + modifier = modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.visa_tx_dispute_button), + iconResId = R.drawable.ic_alert_triangle_20, + onClick = onClick, + ) } // region Preview @@ -249,20 +246,8 @@ private class VisaTxDetailsBottomSheetParameterProvider : txStatus = "confirmed", onExploreClick = {}, ), - VisaTxDetailsBottomSheetConfig.Request( - id = "524582128501966799", - type = "settlement", - status = "accepted", - blockchainAmount = "1.0614 USDT", - transactionAmount = "0.99 €", - currencyCode = "978", - errorCode = 0, - date = "2023-12-01 00:01:00.000 +0300", - txHash = "0x635841d5fbdf1087cdd929019c863ee88a7165e4340bc17ddd0b1d04dfb11daa", - txStatus = "confirmed", - onExploreClick = {}, - ), ), + onDisputeClick = {}, ), ), ) diff --git a/features/walletconnect/impl/build.gradle.kts b/features/walletconnect/impl/build.gradle.kts index e85bbfc291..841499063e 100644 --- a/features/walletconnect/impl/build.gradle.kts +++ b/features/walletconnect/impl/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.ui) implementation(projects.common.routing) + implementation(projects.common.ui) /** Domain models */ implementation(projects.domain.qrScanning.models) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponent.kt new file mode 100644 index 0000000000..a03ae3a26d --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/AlertsComponent.kt @@ -0,0 +1,51 @@ +package com.tangem.features.walletconnect.connections.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.walletconnect.connections.ui.AlertsModalBottomSheet + +internal class AlertsComponent( + appComponentContext: AppComponentContext, + private val params: Params, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + params.alertType.onDismiss() + } + + @Composable + override fun BottomSheet() { + AlertsModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = params.alertType.onDismiss, + content = params.alertType, + ), + ) + } + + internal data class Params(val alertType: AlertType) + + @Immutable + internal sealed class AlertType : TangemBottomSheetConfigContent { + abstract val onDismiss: () -> Unit + + data class VerifiedDomain(val appName: String, override val onDismiss: () -> Unit) : AlertType() + data class UnknownDomain(val onConnect: () -> Unit, override val onDismiss: () -> Unit) : AlertType() + data class UnsafeDomain(val onConnect: () -> Unit, override val onDismiss: () -> Unit) : AlertType() + data class UnsupportedNetworks(val appName: String, override val onDismiss: () -> Unit) : AlertType() + data class WcDisconnected(override val onDismiss: () -> Unit) : AlertType() + data class UnknownError(val errorCode: Int, override val onDismiss: () -> Unit) : AlertType() + data class WrongCardSelected(override val onDismiss: () -> Unit) : AlertType() + data class ConnectionTimeout(val onTryAgain: () -> Unit, override val onDismiss: () -> Unit) : AlertType() + data class MaliciousTransaction( + val descriptionMessage: String, + val onConnect: () -> Unit, + override val onDismiss: () -> Unit, + ) : AlertType() + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcAppInfoComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcAppInfoComponent.kt new file mode 100644 index 0000000000..bba2e61463 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcAppInfoComponent.kt @@ -0,0 +1,31 @@ +package com.tangem.features.walletconnect.connections.components + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.features.walletconnect.connections.ui.WcAppInfoModalBottomSheet + +internal class WcAppInfoComponent( + private val appComponentContext: AppComponentContext, + private val params: Params, +) : AppComponentContext by appComponentContext, ComposableBottomSheetComponent { + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + WcAppInfoModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = TangemBottomSheetConfigContent.Empty, // todo(wc) will be fixed in next PR's + ), + ) + } + + data class Params(val onDismiss: () -> Unit) +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcAppInfoUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcAppInfoUM.kt new file mode 100644 index 0000000000..a588d1ddd0 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcAppInfoUM.kt @@ -0,0 +1,25 @@ +package com.tangem.features.walletconnect.connections.entity + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class WcAppInfoUM( + val appName: String, + val appIcon: String, + val isVerified: Boolean, + val appSubtitle: String, + val notificationUM: NotificationUM?, + val walletName: String, + val networksInfo: WcNetworksInfo, + val onDismiss: () -> Unit, + val onConnect: () -> Unit, +) : TangemBottomSheetConfigContent + +@Immutable +internal sealed class WcNetworksInfo { + data class MissingRequiredNetworkInfo(val network: String) : WcNetworksInfo() + data class ContainsAllRequiredNetworks(val icons: ImmutableList) : WcNetworksInfo() +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/AlertsModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/AlertsModalBottomSheet.kt new file mode 100644 index 0000000000..ae14e28008 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/AlertsModalBottomSheet.kt @@ -0,0 +1,355 @@ +package com.tangem.features.walletconnect.connections.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SecondaryButtonIconEnd +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.walletconnect.connections.components.AlertsComponent + +@Composable +internal fun AlertsModalBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + title = { alert -> + TangemModalBottomSheetTitle( + endIconRes = R.drawable.ic_close_24, + onEndClick = alert.onDismiss, + ) + }, + content = { alert -> Content(alert = alert) }, + ) +} + +@Composable +private fun Content(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + ContentContainer( + modifier = Modifier + .heightIn(min = TangemTheme.dimens.size180) + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16) + .padding(bottom = 32.dp), + alert = alert, + ) + ButtonsContainer(modifier = Modifier.fillMaxWidth(), alert = alert) + } +} + +@Composable +private fun ContentContainer(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + AlertIcon(alert = alert) + AlertContentTitle( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing24), + alert = alert, + ) + AlertContentDescription( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing8), + alert = alert, + ) + AlertAudit(alert, modifier = Modifier.padding(top = TangemTheme.dimens.spacing16)) + } +} + +@Suppress("LongMethod") +@Composable +private fun ButtonsContainer(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(all = TangemTheme.dimens.spacing16), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + val buttonsModifier = Modifier.fillMaxWidth() + when (alert) { + is AlertsComponent.AlertType.UnsafeDomain -> { + PrimaryButton( + modifier = buttonsModifier, + text = stringResourceSafe(R.string.common_cancel), + onClick = alert.onDismiss, + ) + SecondaryButton( + modifier = buttonsModifier, + onClick = alert.onConnect, + text = stringResourceSafe(R.string.wc_alert_connect_anyway), + ) + } + is AlertsComponent.AlertType.MaliciousTransaction -> { + PrimaryButton( + modifier = buttonsModifier, + text = stringResourceSafe(R.string.common_cancel), + onClick = alert.onDismiss, + ) + SecondaryButtonIconEnd( + modifier = buttonsModifier, + onClick = alert.onConnect, + iconResId = R.drawable.ic_tangem_24, + text = stringResourceSafe(R.string.wc_alert_connect_anyway), + ) + } + is AlertsComponent.AlertType.UnknownDomain -> { + PrimaryButton( + modifier = buttonsModifier, + text = stringResourceSafe(R.string.common_cancel), + onClick = alert.onDismiss, + ) + SecondaryButton( + modifier = buttonsModifier, + onClick = alert.onConnect, + text = stringResourceSafe(R.string.wc_alert_connect_anyway), + ) + } + is AlertsComponent.AlertType.VerifiedDomain -> SecondaryButton( + modifier = buttonsModifier, + text = stringResourceSafe(R.string.common_done), + onClick = alert.onDismiss, + ) + is AlertsComponent.AlertType.ConnectionTimeout -> { + PrimaryButton( + modifier = buttonsModifier, + onClick = alert.onTryAgain, + text = stringResourceSafe(R.string.alert_button_try_again), + ) + SecondaryButton( + modifier = buttonsModifier, + onClick = alert.onDismiss, + text = stringResourceSafe(R.string.balance_hidden_got_it_button), + ) + } + is AlertsComponent.AlertType.WrongCardSelected, + is AlertsComponent.AlertType.UnknownError, + -> SecondaryButton( + modifier = buttonsModifier, + onClick = alert.onDismiss, + text = stringResourceSafe(R.string.balance_hidden_got_it_button), + ) + is AlertsComponent.AlertType.WcDisconnected, + is AlertsComponent.AlertType.UnsupportedNetworks, + -> PrimaryButton( + modifier = buttonsModifier, + onClick = alert.onDismiss, + text = stringResourceSafe(R.string.balance_hidden_got_it_button), + ) + } + } +} + +@Composable +private fun AlertIcon(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + val color = when (alert) { + is AlertsComponent.AlertType.VerifiedDomain -> TangemTheme.colors.icon.accent + is AlertsComponent.AlertType.WcDisconnected, + is AlertsComponent.AlertType.ConnectionTimeout, + is AlertsComponent.AlertType.UnsupportedNetworks, + -> TangemTheme.colors.icon.informative + is AlertsComponent.AlertType.WrongCardSelected, + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnknownError, + -> TangemTheme.colors.icon.attention + is AlertsComponent.AlertType.MaliciousTransaction, + is AlertsComponent.AlertType.UnsafeDomain, + -> TangemTheme.colors.icon.warning + } + + @DrawableRes val drawableId = when (alert) { + is AlertsComponent.AlertType.WcDisconnected, + is AlertsComponent.AlertType.ConnectionTimeout, + -> R.drawable.ic_wallet_connect_24 + is AlertsComponent.AlertType.MaliciousTransaction -> R.drawable.img_knight_shield_32 + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnsafeDomain, + -> R.drawable.img_knight_shield_32 + is AlertsComponent.AlertType.UnsupportedNetworks -> R.drawable.ic_network_new_24 + is AlertsComponent.AlertType.WrongCardSelected, + is AlertsComponent.AlertType.UnknownError, + -> R.drawable.img_attention_20 + is AlertsComponent.AlertType.VerifiedDomain -> R.drawable.img_approvale2_20 + } + val iconTint = when (alert) { + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnsafeDomain, + is AlertsComponent.AlertType.MaliciousTransaction, + is AlertsComponent.AlertType.UnsupportedNetworks, + is AlertsComponent.AlertType.ConnectionTimeout, + is AlertsComponent.AlertType.WcDisconnected, + -> color + is AlertsComponent.AlertType.VerifiedDomain, + is AlertsComponent.AlertType.UnknownError, + is AlertsComponent.AlertType.WrongCardSelected, + -> Color.Unspecified + } + Box( + modifier = modifier + .size(TangemTheme.dimens.size56) + .clip(CircleShape) + .background(color.copy(alpha = 0.1F)), + contentAlignment = Alignment.Center, + content = { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size32), + painter = painterResource(drawableId), + contentDescription = null, + tint = iconTint, + ) + }, + ) +} + +@Composable +private fun AlertContentTitle(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + @StringRes val titleRes: Int = when (alert) { + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnsafeDomain, + is AlertsComponent.AlertType.MaliciousTransaction, + -> R.string.security_alert_title + is AlertsComponent.AlertType.ConnectionTimeout -> R.string.wc_alert_connection_timeout_title + is AlertsComponent.AlertType.UnknownError -> R.string.wc_alert_unknown_error_title + is AlertsComponent.AlertType.UnsupportedNetworks -> R.string.wc_alert_unsupported_networks_title + is AlertsComponent.AlertType.VerifiedDomain -> R.string.wc_alert_verified_domain_title + is AlertsComponent.AlertType.WcDisconnected -> R.string.wc_alert_session_disconnected_title + is AlertsComponent.AlertType.WrongCardSelected -> R.string.wc_alert_wrong_card_title + } + Text( + modifier = modifier, + text = stringResourceSafe(titleRes), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun AlertContentDescription(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + val message = when (alert) { + is AlertsComponent.AlertType.ConnectionTimeout -> stringResourceSafe( + R.string.wc_alert_connection_timeout_description, + ) + is AlertsComponent.AlertType.MaliciousTransaction -> alert.descriptionMessage + is AlertsComponent.AlertType.UnknownDomain, + is AlertsComponent.AlertType.UnsafeDomain, + -> stringResourceSafe(R.string.wc_alert_domain_issues_description) + is AlertsComponent.AlertType.UnknownError -> stringResourceSafe( + R.string.wc_alert_unknown_error_description, + alert.errorCode, + ) + is AlertsComponent.AlertType.UnsupportedNetworks -> stringResourceSafe( + R.string.wc_alert_unsupported_networks_description, + alert.appName, + ) + is AlertsComponent.AlertType.VerifiedDomain -> stringResourceSafe( + R.string.wc_alert_verified_domain_description, + alert.appName, + ) + is AlertsComponent.AlertType.WcDisconnected -> stringResourceSafe( + R.string.wc_alert_session_disconnected_description, + ) + is AlertsComponent.AlertType.WrongCardSelected -> stringResourceSafe(R.string.wc_alert_wrong_card_description) + } + Text( + modifier = modifier, + text = message, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun AlertAudit(alert: AlertsComponent.AlertType, modifier: Modifier = Modifier) { + when (alert) { + is AlertsComponent.AlertType.UnknownDomain -> Text( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius16), + color = TangemTheme.colors.text.primary1.copy(alpha = 0.1F), + ) + .padding(vertical = TangemTheme.dimens.spacing4, horizontal = TangemTheme.dimens.spacing12), + text = stringResourceSafe(R.string.wc_alert_audit_unknown_domain), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + is AlertsComponent.AlertType.UnsafeDomain -> Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius16), + color = TangemTheme.colors.text.warning.copy(alpha = 0.1F), + ) + .padding(vertical = TangemTheme.dimens.spacing4, horizontal = TangemTheme.dimens.spacing12), + text = stringResourceSafe(R.string.wc_alert_audit_unknown_domain), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.warning, + ) + is AlertsComponent.AlertType.ConnectionTimeout, + is AlertsComponent.AlertType.MaliciousTransaction, + is AlertsComponent.AlertType.UnknownError, + is AlertsComponent.AlertType.UnsupportedNetworks, + is AlertsComponent.AlertType.VerifiedDomain, + is AlertsComponent.AlertType.WcDisconnected, + is AlertsComponent.AlertType.WrongCardSelected, + -> Unit + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AlertsModalBottomSheet_Preview( + @PreviewParameter(AlertTypesProvider::class) alert: AlertsComponent.AlertType, +) { + TangemThemePreview { + AlertsModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = alert, + ), + ) + } +} + +private class AlertTypesProvider : CollectionPreviewParameterProvider( + collection = listOf( + AlertsComponent.AlertType.VerifiedDomain(appName = "React App", onDismiss = {}), + AlertsComponent.AlertType.UnknownDomain(onConnect = {}, onDismiss = {}), + AlertsComponent.AlertType.UnsafeDomain(onConnect = {}, onDismiss = {}), + AlertsComponent.AlertType.MaliciousTransaction( + descriptionMessage = "The transaction approves erc20 tokens to a known malicious address", + onConnect = {}, + onDismiss = {}, + ), + AlertsComponent.AlertType.UnsupportedNetworks(appName = "React App", onDismiss = {}), + AlertsComponent.AlertType.WcDisconnected(onDismiss = {}), + AlertsComponent.AlertType.UnknownError(errorCode = 8005, onDismiss = {}), + AlertsComponent.AlertType.WrongCardSelected(onDismiss = {}), + AlertsComponent.AlertType.ConnectionTimeout(onTryAgain = {}, onDismiss = {}), + ), +) \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt new file mode 100644 index 0000000000..ca17e5f44f --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/InternalComponents.kt @@ -0,0 +1,69 @@ +package com.tangem.features.walletconnect.connections.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.walletconnect.impl.R + +@Composable +internal fun WcAppInfoItem( + iconUrl: String, + title: String, + subtitle: String, + isVerified: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(TangemTheme.dimens.spacing12), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + AsyncImage( + modifier = Modifier + .size(TangemTheme.dimens.size48) + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .background(Color.Black), + model = iconUrl, + contentDescription = title, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = title, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.h3, + ) + if (isVerified) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.img_approvale2_20), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } + Text( + text = subtitle, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + ) + } + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoContent.kt new file mode 100644 index 0000000000..5ce783ebb7 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcAppInfoContent.kt @@ -0,0 +1,447 @@ +package com.tangem.features.walletconnect.connections.ui + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.walletconnect.connections.entity.WcAppInfoUM +import com.tangem.features.walletconnect.connections.entity.WcNetworksInfo +import com.tangem.features.walletconnect.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun WcAppInfoModalBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.tertiary, + title = { state -> + TangemModalBottomSheetTitle( + title = resourceReference(R.string.wc_wallet_connect), + endIconRes = R.drawable.ic_close_24, + onEndClick = state.onDismiss, + ) + }, + content = { state -> WcAppInfoModalBottomSheetContent(state) }, + ) +} + +@Composable +private fun WcAppInfoModalBottomSheetContent(state: WcAppInfoUM) { + Column(modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16)) { + val blocksModifier = Modifier + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) + .background(color = TangemTheme.colors.background.action) + .fillMaxWidth() + WcAppInfoFirstBlock(modifier = blocksModifier, state = state) + state.notificationUM?.config?.let { config -> + Notification( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing14), + config = config, + iconTint = TangemTheme.colors.icon.attention, + ) + } + WcAppInfoSecondBlock( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing14) + .then(blocksModifier), + state = state, + ) + WcAppInfoButtons( + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing16), + onDismiss = state.onDismiss, + onConnect = state.onConnect, + ) + } +} + +@Composable +private fun WcAppInfoFirstBlock(state: WcAppInfoUM, modifier: Modifier = Modifier) { + var connectionRequestExpanded by remember { mutableStateOf(false) } + Column(modifier = modifier.animateContentSize()) { + WcAppInfoItem( + iconUrl = state.appIcon, + title = state.appName, + subtitle = state.appSubtitle, + isVerified = state.isVerified, + ) + HorizontalDivider(thickness = 1.dp, color = TangemTheme.colors.stroke.primary) + ConnectionRequestBlock( + modifier = Modifier + .fillMaxWidth() + .clickable { connectionRequestExpanded = !connectionRequestExpanded } + .padding(TangemTheme.dimens.spacing12), + expanded = connectionRequestExpanded, + ) + if (connectionRequestExpanded) { + ConnectionRequestDescription( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing12) + .padding(top = 8.dp, bottom = 12.dp), + ) + } + } +} + +@Composable +private fun ConnectionRequestBlock(expanded: Boolean, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_connect_new_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .weight(1f), + text = stringResourceSafe(R.string.wc_connection_request), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Icon( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .size(width = 18.dp, height = 24.dp), + painter = painterResource(if (expanded) R.drawable.ic_chevron_up_24 else R.drawable.ic_chevron_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Composable +private fun ConnectionRequestDescription(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text( + text = stringResourceSafe(R.string.wc_connection_reqeust_would_like), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + ConnectionRequestDescriptionRow( + iconPainter = painterResource(R.drawable.ic_check_24), + tint = TangemTheme.colors.icon.accent, + text = stringResourceSafe(R.string.wc_connection_reqeust_can_view_balance), + ) + ConnectionRequestDescriptionRow( + iconPainter = painterResource(R.drawable.ic_check_24), + tint = TangemTheme.colors.icon.accent, + text = stringResourceSafe(R.string.wc_connection_reqeust_request_approval), + ) + HorizontalDivider( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + thickness = 0.5.dp, + color = TangemTheme.colors.stroke.primary, + ) + Text( + modifier = Modifier.padding(top = 12.dp), + text = stringResourceSafe(R.string.wc_connection_reqeust_will_not), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + ) + ConnectionRequestDescriptionRow( + iconPainter = painterResource(R.drawable.ic_close_24), + tint = TangemTheme.colors.icon.warning, + text = stringResourceSafe(R.string.wc_connection_reqeust_cant_sign), + ) + } +} + +@Composable +private fun ConnectionRequestDescriptionRow( + iconPainter: Painter, + tint: Color, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing8), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size24), + painter = iconPainter, + contentDescription = null, + tint = tint, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = text, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +private fun WcAppInfoSecondBlock(state: WcAppInfoUM, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + val itemsModifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing12) + WalletRowItem(modifier = itemsModifier, walletName = state.walletName) + HorizontalDivider(thickness = 1.dp, color = TangemTheme.colors.stroke.primary) + SelectNetworksBlock( + networksInfo = state.networksInfo, + modifier = itemsModifier, + ) + when (state.networksInfo) { + is WcNetworksInfo.ContainsAllRequiredNetworks -> Unit + is WcNetworksInfo.MissingRequiredNetworkInfo -> { + HorizontalDivider(thickness = 1.dp, color = TangemTheme.colors.stroke.primary) + Notification( + config = NotificationConfig( + iconResId = R.drawable.ic_alert_circle_24, + title = resourceReference(R.string.wc_missing_required_network_title), + subtitle = resourceReference( + R.string.wc_missing_required_network_description, + wrappedList(state.networksInfo.network), + ), + ), + iconTint = TangemTheme.colors.icon.attention, + containerColor = TangemTheme.colors.background.action, + ) + } + } + } +} + +@Composable +private fun WalletRowItem(walletName: String, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_wallet_new_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .weight(1f), + text = stringResourceSafe(R.string.manage_tokens_network_selector_wallet), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing16) + .weight(1f), + text = walletName, + textAlign = TextAlign.End, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + Icon( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .size(width = 18.dp, height = 24.dp), + painter = painterResource(R.drawable.ic_select_18_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Composable +private fun SelectNetworksBlock(networksInfo: WcNetworksInfo, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_network_new_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + Text( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .weight(1f), + text = stringResourceSafe(R.string.wc_common_networks), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + when (networksInfo) { + is WcNetworksInfo.ContainsAllRequiredNetworks -> NetworkIcons(icons = networksInfo.icons) + is WcNetworksInfo.MissingRequiredNetworkInfo -> Unit + } + Icon( + modifier = Modifier.size(width = 18.dp, height = 24.dp), + painter = painterResource( + id = when (networksInfo) { + is WcNetworksInfo.ContainsAllRequiredNetworks -> R.drawable.ic_select_18_24 + is WcNetworksInfo.MissingRequiredNetworkInfo -> R.drawable.ic_chevron_right_24 + }, + ), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Suppress("MagicNumber") +@Composable +private fun NetworkIcons(icons: ImmutableList, modifier: Modifier = Modifier) { + val (iconsToShow, remainingCount) = remember(icons) { + when { + icons.size <= 4 -> icons to 0 + else -> icons.take(3) to icons.size - 3 + } + } + Box(modifier = modifier.wrapContentWidth()) { + iconsToShow.forEachIndexed { index, iconRes -> + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing16.times(index)) + .border( + width = 2.dp, + color = TangemTheme.colors.background.action, + shape = CircleShape, + ) + .padding(2.dp) + .clip(CircleShape) + .size(20.dp), + ) + } + if (remainingCount > 0) { + Box( + modifier = modifier + .padding(start = 48.dp) + .border( + width = 2.dp, + color = TangemTheme.colors.background.action, + shape = CircleShape, + ) + .padding(2.dp) + .background(color = TangemTheme.colors.background.action) + .size(20.dp) + .clip(CircleShape) + .background(color = TangemTheme.colors.icon.primary1.copy(alpha = 0.1F)), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "+$remainingCount", + style = TangemTheme.typography.overline, + color = TangemTheme.colors.text.secondary, + ) + } + } + } +} + +@Composable +private fun WcAppInfoButtons(onDismiss: () -> Unit, onConnect: () -> Unit, modifier: Modifier = Modifier) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { + SecondaryButton( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + text = stringResourceSafe(R.string.common_cancel), + onClick = onDismiss, + ) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + text = stringResourceSafe(R.string.wc_common_connect), + onClick = onConnect, + ) + } +} + +@Composable +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun WcAppInfoBottomSheetPreview(@PreviewParameter(WcAppInfoStateProvider::class) state: WcAppInfoUM) { + TangemThemePreview { + WcAppInfoModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = state, + ), + ) + } +} + +private class WcAppInfoStateProvider : CollectionPreviewParameterProvider( + collection = listOf( + WcAppInfoUM( + appName = "React App", + appIcon = "", + isVerified = true, + appSubtitle = "react-app.walletconnect.com", + notificationUM = null, + walletName = "Tangem 2.0", + networksInfo = WcNetworksInfo.ContainsAllRequiredNetworks( + icons = persistentListOf( + R.drawable.img_optimism_22, + R.drawable.img_bsc_22, + R.drawable.img_avalanche_22, + R.drawable.img_solana_22, + R.drawable.img_avalanche_22, + ), + ), + onDismiss = {}, + onConnect = {}, + ), + WcAppInfoUM( + appName = "React App", + appIcon = "", + isVerified = false, + appSubtitle = "react-app.walletconnect.com", + notificationUM = NotificationUM.Info( + title = resourceReference(R.string.wc_alert_audit_unknown_domain), + subtitle = resourceReference(R.string.wc_alert_domain_issues_description), + ), + walletName = "Tangem 2.0", + networksInfo = WcNetworksInfo.MissingRequiredNetworkInfo(network = "Solana"), + onDismiss = {}, + onConnect = {}, + ), + ), +) \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 8fbee2f5e1..3f1defd17d 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -102,6 +102,7 @@ espresso-intents = "3.5.1" junit = "4.13.2" junitAndroidExt = "1.1.5" mockk = "1.13.4" +turbine = "1.2.0" truth = "1.1.3" ultron-android = "2.5.4" ultron-compose = "2.5.4" @@ -199,6 +200,7 @@ test-junit = { module = "junit:junit", version.ref = "junit" } test-junit-android = { module = "androidx.test.ext:junit", version.ref = "junitAndroidExt" } test-truth = { module = "com.google.truth:truth", version.ref = "truth" } test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +test-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } test-ultron-android = { module = "com.atiurin:ultron-android", version.ref = "ultron-android" } test-ultron-compose = { module = "com.atiurin:ultron-compose", version.ref = "ultron-compose" } test-ultron-allure = { module = "com.atiurin:ultron-allure", version.ref = "ultron-allure" } diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index 3decd72093..71205a6291 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -72,6 +72,11 @@ object BlockchainUtils { return blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet } + fun isTon(blockchainId: String): Boolean { + val blockchain = Blockchain.fromId(blockchainId) + return blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet + } + fun isSupportedNetworkId(blockchainId: String, excludedBlockchains: ExcludedBlockchains): Boolean { val blockchain = Blockchain.fromNetworkId(blockchainId) diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt index 8bdae098a7..baed3f3725 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt @@ -1,6 +1,7 @@ package com.tangem.lib.crypto import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.lib.crypto.models.* import java.math.BigInteger @@ -14,7 +15,7 @@ interface TransactionManager { * @param currencyToSend currency to send in tx * @param destinationAddress address to send tx * @param increaseBy percents in format 125 = 25% - * @param data data for tx + * @param callData tx smart contract call data * @param derivationPath derivation path * @return */ @@ -26,7 +27,7 @@ interface TransactionManager { currencyToSend: Currency, destinationAddress: String, increaseBy: Int?, - data: String?, + callData: SmartContractCallData?, derivationPath: String?, ): ProxyFees diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt index 6a6cfde251..e0a1deedfc 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt @@ -1,5 +1,7 @@ package com.tangem.sdk.api.di +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier import dagger.Module import dagger.Provides @@ -14,4 +16,9 @@ internal object CardSdkModule { @Provides @Singleton fun provideOnlineCardVerifier(): OnlineCardVerifier = OnlineCardVerifier() + + @Provides + @Singleton + fun provideCardArtworksProvider(sdkRepository: CardSdkConfigRepository): CardArtworksProvider = + CardArtworksProvider(sdkRepository.sdk.config.isTangemAttestationProdEnv) } \ No newline at end of file diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt index b90f1577cc..7eb52af432 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt @@ -8,4 +8,6 @@ package com.tangem.sdk.api.featuretoggles interface CardSdkFeatureToggles { val isNewAttestationEnabled: Boolean + + val isNewArtworkLoadingEnabled: Boolean } \ No newline at end of file diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt index 4146304590..0a12b3ce37 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt @@ -9,4 +9,7 @@ internal class DefaultCardSdkFeatureToggles @Inject constructor( override val isNewAttestationEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(name = "NEW_ATTESTATION_ENABLED") + + override val isNewArtworkLoadingEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "NEW_ARTWORK_LOADING") } \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt index 89c5d2f72d..03d2b74d0c 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt @@ -69,17 +69,19 @@ private fun AppExtension.configureBuildTypes() { private fun AndroidBuildType.configureBuildVariant(appExtension: AppExtension, buildType: BuildType) { when (buildType) { + BuildType.Release -> { + isDebuggable = false + } BuildType.Debug -> { isDebuggable = true } - BuildType.Internal -> { + BuildType.Internal, + BuildType.External + -> { initWith(appExtension.buildTypes.getByName(BuildType.Release.id)) matchingFallbacks.add(BuildType.Release.id) signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id) } - BuildType.Release -> { - isDebuggable = false - } BuildType.Mocked -> { initWith(appExtension.buildTypes.getByName(BuildType.Release.id)) matchingFallbacks.add(BuildType.Release.id) diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt index 485c960dcb..bac38b3402 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt @@ -78,6 +78,27 @@ internal enum class BuildType( ), ), + /** + * Build type for QA and business + * + * Features: + * - Env: prod + * - Signing config: debug + * - Proguard + * */ + External( + id = "external", + appIdSuffix = "external", + versionSuffix = "external", + configFields = listOf( + BuildConfigField.Environment(value = "prod"), + BuildConfigField.TestActionEnabled(isEnabled = false), + BuildConfigField.LogEnabled(isEnabled = false), + BuildConfigField.TesterMenuAvailability(isEnabled = false), + BuildConfigField.MockDataSource(isEnabled = false), + ), + ), + /** * Production ready build type for clients * diff --git a/settings.gradle.kts b/settings.gradle.kts index ddcc0c9bc5..01e4240001 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -288,6 +288,9 @@ include(":domain:promo:models") include(":domain:nft") include(":domain:nft:models") include(":domain:networks") +include(":domain:quotes") +include(":domain:blockaid") +include(":domain:blockaid:models") // endregion Domain modules // region Data modules @@ -314,4 +317,6 @@ include(":data:manage-tokens") include(":data:networks") include(":data:nft") include(":data:onramp") +include(":data:quotes") +include(":data:blockaid") // endregion Data modules \ No newline at end of file