diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5a1e6a705..b36d773b95 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,5 @@ -import java.util.Properties import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants +import java.util.Properties plugins { alias(deps.plugins.android.application) @@ -100,6 +100,7 @@ dependencies { implementation(projects.domain.qrScanning.models) implementation(projects.domain.staking) implementation(projects.domain.walletConnect) + implementation(projects.domain.walletConnect.models) implementation(projects.domain.markets) implementation(projects.domain.manageTokens) implementation(projects.domain.nft) @@ -107,6 +108,7 @@ dependencies { implementation(projects.domain.onramp) implementation(projects.domain.promo) implementation(projects.domain.promo.models) + implementation(projects.domain.networks) implementation(projects.common) implementation(projects.common.routing) @@ -121,6 +123,7 @@ dependencies { implementation(projects.core.utils) implementation(projects.core.decompose) implementation(projects.core.deepLinks) + implementation(projects.core.error.ext) implementation(projects.libs.crypto) implementation(projects.libs.auth) implementation(projects.libs.blockchainSdk) @@ -148,6 +151,7 @@ dependencies { implementation(projects.data.manageTokens) implementation(projects.data.nft) implementation(projects.data.onramp) + implementation(projects.data.networks) /** Features */ implementation(projects.features.onboarding) @@ -169,6 +173,8 @@ dependencies { implementation(projects.features.manageTokens.impl) implementation(projects.features.send.api) implementation(projects.features.send.impl) + implementation(projects.features.sendV2.api) + implementation(projects.features.sendV2.impl) implementation(projects.features.qrScanning.api) implementation(projects.features.qrScanning.impl) implementation(projects.features.staking.api) @@ -195,6 +201,8 @@ dependencies { implementation(projects.features.biometry.impl) implementation(projects.features.nft.api) implementation(projects.features.nft.impl) + implementation(projects.features.walletconnect.api) + implementation(projects.features.walletconnect.impl) /** AndroidX libraries */ implementation(deps.androidx.core.ktx) @@ -286,6 +294,7 @@ dependencies { kaptForObfuscatingVariants(deps.retrofit.response.type.keeper) /** Testing libraries */ + testImplementation(projects.common.test) testImplementation(deps.test.coroutine) testImplementation(deps.test.junit) testImplementation(deps.test.mockk) @@ -308,9 +317,7 @@ dependencies { /** Chucker */ debugImplementation(deps.chucker) - debugPGImplementation(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 8e5de701e0..29f73d909e 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 8e5de701e02a2d48a68e32cd4783549dde01983e +Subproject commit 29f73d909e14cb3760118b197b1929a6088bc40e diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 2b76d7c7eb..ee95e2ba40 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -3,7 +3,6 @@ package com.tangem.tap import androidx.hilt.work.HiltWorkerFactory import com.tangem.TangemSdkLogger import com.tangem.blockchainsdk.BlockchainSDKFactory -import com.tangem.data.card.TransactionSignerFactory import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.filter.OneTimeEventFilter @@ -15,6 +14,7 @@ import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.data.card.TransactionSignerFactory import com.tangem.datasource.connection.NetworkConnectionManager import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.datasource.local.config.issuers.IssuersConfigStorage @@ -32,13 +32,13 @@ import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.onramp.OnrampFeatureToggles +import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.log.TangemAppLoggerInitializer import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles @@ -94,8 +94,6 @@ interface ApplicationEntryPoint { fun getSaveTwinsOnboardingShownUseCase(): SaveTwinsOnboardingShownUseCase - fun getWalletNameGenerateUseCase(): GenerateWalletNameUseCase - fun getCardRepository(): CardRepository fun getTangemSdkLogger(): TangemSdkLogger @@ -118,8 +116,6 @@ interface ApplicationEntryPoint { fun getTransactionSignerFactory(): TransactionSignerFactory - fun getGetUserCountryCodeUseCase(): GetUserCountryUseCase - fun getOnrampFeatureToggles(): OnrampFeatureToggles fun getOnboardingV2FeatureToggles(): OnboardingV2FeatureToggles @@ -142,4 +138,8 @@ interface ApplicationEntryPoint { fun getUiMessageSender(): UiMessageSender fun getWorkerFactory(): HiltWorkerFactory + + fun getOnlineCardVerifier(): OnlineCardVerifier + + fun getUserWalletBuilderFactory(): UserWalletBuilder.Factory } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 27403d8cd3..3eb6102ce8 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -299,9 +299,12 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac } private fun setRootContent() { + // for now activity is singleTop and after going to ChromeCustomTab it calls onCreate but onDestroy + // doesn't calls. It lead to issue that decompose nav stack is not saved in bundle and to restore it + // we try to init component with previous stack val routingComponent = routingComponentFactory.create( context = rootComponentContext, - initialStack = null, + initialStack = appRouterConfig.stack, ) setContent { @@ -489,7 +492,9 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac * `android:configChanges="uiMode"` is set in the manifest. * */ - updateAppBackground() + if (routingFeatureToggles.isNavigationRefactoringEnabled.not()) { + updateAppBackground() + } } private fun updateAppBackground() { diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 32e3b46f66..4cc52e4409 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -8,6 +8,7 @@ import coil.ImageLoaderFactory import com.chuckerteam.chucker.api.ChuckerInterceptor import com.tangem.Log import com.tangem.TangemSdkLogger +import com.tangem.blockchain.common.ExceptionHandler import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder import com.tangem.blockchainsdk.BlockchainSDKFactory import com.tangem.blockchainsdk.utils.ExcludedBlockchains @@ -46,15 +47,17 @@ import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.onramp.OnrampFeatureToggles +import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.operations.attestation.api.TangemApiServiceLogging import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder +import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler import com.tangem.tap.common.images.createCoilImageLoader @@ -146,9 +149,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase get() = entryPoint.getSaveTwinsOnboardingShownUseCase() - private val generateWalletNameUseCase: GenerateWalletNameUseCase - get() = entryPoint.getWalletNameGenerateUseCase() - private val cardRepository: CardRepository get() = entryPoint.getCardRepository() @@ -182,9 +182,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val transactionSignerFactory: TransactionSignerFactory get() = entryPoint.getTransactionSignerFactory() - private val getUserCountryUseCase: GetUserCountryUseCase - get() = entryPoint.getGetUserCountryCodeUseCase() - private val onrampFeatureToggles: OnrampFeatureToggles get() = entryPoint.getOnrampFeatureToggles() @@ -212,6 +209,9 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat private val uiMessageSender: UiMessageSender get() = entryPoint.getUiMessageSender() + private val blockchainExceptionHandler: BlockchainExceptionHandler + get() = entryPoint.getBlockchainExceptionHandler() + private val workerFactory: HiltWorkerFactory get() = entryPoint.getWorkerFactory() @@ -220,6 +220,12 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat .setWorkerFactory(workerFactory) .build() + private val onlineCardVerifier: OnlineCardVerifier + get() = entryPoint.getOnlineCardVerifier() + + private val userWalletBuilderFactory: UserWalletBuilder.Factory + get() = entryPoint.getUserWalletBuilderFactory() + // endregion override fun onCreate() { @@ -265,12 +271,16 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat } loadNativeLibraries() - // ExceptionHandler.append(blockchainExceptionHandler) // TODO [REDACTED_TASK_KEY] Send only to Firebase + ExceptionHandler.append(blockchainExceptionHandler) if (LogConfig.network.blockchainSdkNetwork) { BlockchainSdkRetrofitBuilder.interceptors = listOf( createNetworkLoggingInterceptor(), ChuckerInterceptor(this), ) + TangemApiServiceLogging.addInterceptors( + createNetworkLoggingInterceptor(), + ChuckerInterceptor(this), + ) } derivationsFinder = DerivationsFinder( @@ -301,7 +311,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat generalUserWalletsListManager = generalUserWalletsListManager, wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, - generateWalletNameUseCase = generateWalletNameUseCase, cardRepository = cardRepository, settingsRepository = settingsRepository, blockchainSDKFactory = blockchainSDKFactory, @@ -312,7 +321,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat shareManager = shareManager, appRouter = appRouter, transactionSignerFactory = transactionSignerFactory, - getUserCountryUseCase = getUserCountryUseCase, onrampFeatureToggles = onrampFeatureToggles, environmentConfigStorage = environmentConfigStorage, onboardingV2FeatureToggles = onboardingV2FeatureToggles, @@ -322,6 +330,8 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat clipboardManager = clipboardManager, settingsManager = settingsManager, uiMessageSender = uiMessageSender, + onlineCardVerifier = onlineCardVerifier, + userWalletBuilderFactory = userWalletBuilderFactory, ), ), ) diff --git a/app/src/main/java/com/tangem/tap/common/DialogManager.kt b/app/src/main/java/com/tangem/tap/common/DialogManager.kt index c69530d3be..533cdac6a9 100644 --- a/app/src/main/java/com/tangem/tap/common/DialogManager.kt +++ b/app/src/main/java/com/tangem/tap/common/DialogManager.kt @@ -91,8 +91,6 @@ class DialogManager : StoreSubscriber { context = context, ) } - is WalletConnectDialog.ClipboardOrScanQr -> - ClipboardOrScanQrDialog.create(state.dialog.clipboardUri, context) is WalletConnectDialog.RequestTransaction -> TransactionDialog.create(state.dialog.data, context) is WalletConnectDialog.PersonalSign -> PersonalSignDialog.create(state.dialog.data, context) is WalletConnectDialog.BnbTransactionDialog -> diff --git a/app/src/main/java/com/tangem/tap/common/analytics/AnalyticsEventsLogger.kt b/app/src/main/java/com/tangem/tap/common/analytics/AnalyticsEventsLogger.kt index 39291d12f6..29b584fa05 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/AnalyticsEventsLogger.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/AnalyticsEventsLogger.kt @@ -1,20 +1,20 @@ package com.tangem.tap.common.analytics import com.tangem.common.json.MoshiJsonConverter -import com.tangem.core.analytics.api.ErrorEventLogger +import com.tangem.core.analytics.api.ExceptionLogger import com.tangem.core.analytics.api.EventLogger import timber.log.Timber class AnalyticsEventsLogger( private val name: String, private val jsonConverter: MoshiJsonConverter, -) : EventLogger, ErrorEventLogger { +) : EventLogger, ExceptionLogger { override fun logEvent(event: String, params: Map) { Timber.d(jsonConverter.prettyPrint(PrintEventModel(name, event, params))) } - override fun logErrorEvent(error: Throwable, params: Map) { + override fun logException(error: Throwable, params: Map) { Timber.e(error, jsonConverter.prettyPrint(PrintEventModel(name, "error", params))) } } diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainExceptionEvent.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainApiExceptionEvent.kt similarity index 92% rename from app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainExceptionEvent.kt rename to app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainApiExceptionEvent.kt index 385ab3e517..3e687ae5fd 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainExceptionEvent.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/BlockchainApiExceptionEvent.kt @@ -2,7 +2,7 @@ package com.tangem.tap.common.analytics.events import com.tangem.core.analytics.models.AnalyticsEvent -class BlockchainExceptionEvent( +class BlockchainApiExceptionEvent( selectedHost: String, exceptionHost: String, error: String, diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/Push.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/Push.kt index 46c647b06f..c4345c597f 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/Push.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/Push.kt @@ -6,7 +6,6 @@ internal sealed class Push(event: String) : AnalyticsEvent( category = "Push", event = event, params = emptyMap(), - error = null, ) { data object PushNotificationOpened : Push(event = "Push Notification Opened") diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/Settings.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/Settings.kt index 98b0289289..4234256e8c 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/Settings.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/Settings.kt @@ -9,8 +9,7 @@ sealed class Settings( category: String = "Settings", event: String, params: Map = mapOf(), - error: Throwable? = null, -) : AnalyticsEvent(category, event, params, error) { +) : AnalyticsEvent(category, event, params) { class ScreenOpened : Settings(event = "Settings Screen Opened") class ButtonStartWalletConnectSession : Settings(event = "Button - Start Wallet Connect Session") @@ -19,16 +18,14 @@ sealed class Settings( sealed class CardSettings( event: String, params: Map = mapOf(), - error: Throwable? = null, - ) : Settings("Settings / Card Settings", event, params, error) { + ) : Settings("Settings / Card Settings", event, params) { class ButtonFactoryReset : CardSettings("Button - Factory Reset") - class FactoryResetFinished(cardsCount: Int? = null, error: Throwable? = null) : CardSettings( + class FactoryResetFinished(cardsCount: Int? = null) : CardSettings( event = "Factory Reset Finished", params = buildMap { cardsCount?.let { put("Cards Count", "$it") } }, - error = error, ) class FactoryResetCanceled(cardsCount: Int) : CardSettings( @@ -44,10 +41,9 @@ sealed class Settings( params = mapOf("Type" to type.value), ) - class SecurityModeChanged(mode: AnalyticsParam.SecurityMode, error: Throwable? = null) : CardSettings( + class SecurityModeChanged(mode: AnalyticsParam.SecurityMode) : CardSettings( event = "Security Mode Changed", params = mapOf("Mode" to mode.value), - error = error, ) class AccessCodeRecoveryChanged(status: AnalyticsParam.AccessCodeRecoveryStatus) : CardSettings( diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/SignIn.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/SignIn.kt index 212969481a..a9a52c5e25 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/SignIn.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/SignIn.kt @@ -8,8 +8,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent sealed class SignIn( event: String, params: Map = mapOf(), - error: Throwable? = null, -) : AnalyticsEvent("Sign In", event, params, error) { +) : AnalyticsEvent("Sign In", event, params) { class ScreenOpened : SignIn(event = "Sign In Screen Opened") diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/TangemSdkErrorEvent.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/TangemSdkErrorEvent.kt new file mode 100644 index 0000000000..bfd7f4bcc9 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/TangemSdkErrorEvent.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.common.analytics.events + +import com.tangem.common.core.TangemSdkError +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.tap.common.analytics.converters.AnalyticsErrorConverter + +class TangemSdkErrorEvent( + val exception: TangemSdkError, +) : AnalyticsEvent( + category = "TangemSdk", + event = "Tangem Sdk Error", + params = errorConverter.convert(exception), +) { + + companion object { + private val errorConverter = AnalyticsErrorConverter() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt index bf0bdc539a..9f6214a78a 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/Token.kt @@ -10,8 +10,7 @@ sealed class Token( category: String, event: String, params: Map = mapOf(), - error: Throwable? = null, -) : AnalyticsEvent(category, event, params, error) { +) : AnalyticsEvent(category, event, params) { sealed class Receive( event: String, diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt index 60c649ba6e..24566a94f8 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/WalletConnect.kt @@ -9,8 +9,7 @@ import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.O internal sealed class WalletConnect( event: String, params: Map = mapOf(), - error: Throwable? = null, -) : AnalyticsEvent("Wallet Connect", event, params, error) { +) : AnalyticsEvent("Wallet Connect", event, params) { class ScreenOpened : WalletConnect(event = "WC Screen Opened") class NewSessionInitiated(source: SourceType) : WalletConnect( diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/BlockchainExceptionHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/BlockchainExceptionHandler.kt index b227a5f1f1..a1931b993e 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/BlockchainExceptionHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/BlockchainExceptionHandler.kt @@ -1,16 +1,16 @@ package com.tangem.tap.common.analytics.handlers import com.tangem.blockchain.common.ExceptionHandlerOutput -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.tap.common.analytics.events.BlockchainExceptionEvent +import com.tangem.core.analytics.api.AnalyticsErrorHandler +import com.tangem.tap.common.analytics.events.BlockchainApiExceptionEvent import javax.inject.Inject class BlockchainExceptionHandler @Inject constructor( - private val analyticsHandler: AnalyticsEventHandler, + private val analyticsErrorHandler: AnalyticsErrorHandler, ) : ExceptionHandlerOutput { override fun handleApiSwitch(currentHost: String, nextHost: String, message: String) { - analyticsHandler.send( - BlockchainExceptionEvent( + analyticsErrorHandler.sendErrorEvent( + BlockchainApiExceptionEvent( selectedHost = nextHost, exceptionHost = currentHost, error = message, diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseAnalyticsHandler.kt index 10b399382c..298874dcef 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseAnalyticsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseAnalyticsHandler.kt @@ -1,16 +1,18 @@ package com.tangem.tap.common.analytics.handlers.firebase -import com.google.firebase.analytics.FirebaseAnalytics +import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsHandler -import com.tangem.core.analytics.api.ErrorEventHandler +import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.converters.AnalyticsErrorConverter -import com.tangem.tap.common.analytics.events.Shop class FirebaseAnalyticsHandler( private val client: FirebaseAnalyticsClient, -) : AnalyticsHandler, ErrorEventHandler { +) : AnalyticsHandler, AnalyticsErrorHandler, AnalyticsExceptionHandler { + + private val errorConverter = AnalyticsErrorConverter() override fun id(): String = ID @@ -18,30 +20,17 @@ class FirebaseAnalyticsHandler( client.logEvent(eventId, params) } - override fun send(event: AnalyticsEvent) { - val error = event.error - when { - error != null -> { - val errorConverter = AnalyticsErrorConverter() - if (!errorConverter.canBeHandled(error)) return + override fun sendException(event: ExceptionAnalyticsEvent) { + if (!errorConverter.canBeHandled(event.exception)) return - val errorParams = errorConverter.convert(error).toMutableMap() - errorParams["Category"] = event.category - errorParams["Event"] = event.event - errorParams.putAll(event.params) - send(error, errorParams) - } - event is Shop.Purchased -> { - send(FirebaseAnalytics.Event.PURCHASE, event.params) - } - else -> { - super.send(event) - } - } + val errorParams = errorConverter.convert(event.exception).toMutableMap() + errorParams.putAll(event.params) + + client.logException(event.exception, event.params) } - override fun send(error: Throwable, params: Map) { - client.logErrorEvent(error, params) + override fun sendErrorEvent(event: AnalyticsEvent) { + send(event) } companion object { diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseClient.kt index 7e4e5085bc..06ccf50709 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseClient.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseClient.kt @@ -4,14 +4,15 @@ import android.os.Bundle import androidx.core.os.bundleOf import com.google.firebase.analytics.ktx.analytics import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.crashlytics.recordException import com.google.firebase.ktx.Firebase -import com.tangem.core.analytics.api.ErrorEventLogger +import com.tangem.core.analytics.api.ExceptionLogger import com.tangem.core.analytics.api.EventLogger /** [REDACTED_AUTHOR] */ -interface FirebaseAnalyticsClient : EventLogger, ErrorEventLogger +interface FirebaseAnalyticsClient : EventLogger, ExceptionLogger internal class FirebaseClient : FirebaseAnalyticsClient { @@ -27,11 +28,11 @@ internal class FirebaseClient : FirebaseAnalyticsClient { ) } - override fun logErrorEvent(error: Throwable, params: Map) { - eventConverter.convertEventParams(params) - .forEach { fbCrashlytics.setCustomKey(it.key, it.value) } - - fbCrashlytics.recordException(error) + override fun logException(error: Throwable, params: Map) { + fbCrashlytics.recordException(error) { + eventConverter.convertEventParams(params) + .forEach { key(it.key, it.value) } + } } private fun Map.toBundle(): Bundle = bundleOf(*this.toList().toTypedArray()) diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseLogClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseLogClient.kt index e460f9dbf9..4b818bdf51 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseLogClient.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/firebase/FirebaseLogClient.kt @@ -16,7 +16,7 @@ internal class FirebaseLogClient( logger.logEvent(event, params) } - override fun logErrorEvent(error: Throwable, params: Map) { - logger.logErrorEvent(error, params) + override fun logException(error: Throwable, params: Map) { + logger.logException(error, params) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt b/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt index a1751aff85..4fcd790936 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt @@ -10,17 +10,30 @@ import com.tangem.common.authentication.AuthenticationManager import com.tangem.common.card.FirmwareVersion import com.tangem.common.core.Config import com.tangem.common.services.secure.SecureStorage -import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.crypto.bip39.Wordlist import com.tangem.data.card.sdk.CardSdkOwner import com.tangem.data.card.sdk.CardSdkProvider +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectMap import com.tangem.sdk.DefaultSessionViewDelegate +import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles import com.tangem.sdk.extensions.* import com.tangem.sdk.nfc.AndroidNfcAvailabilityProvider import com.tangem.sdk.nfc.NfcManager import com.tangem.sdk.storage.create import com.tangem.tap.foregroundActivityObserver import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton @@ -32,8 +45,11 @@ import javax.inject.Singleton */ @Singleton internal class DefaultCardSdkProvider @Inject constructor( - private val analyticsEventHandler: AnalyticsEventHandler, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val dispatchers: CoroutineDispatcherProvider, + private val cardSdkFeatureToggles: CardSdkFeatureToggles, + private val apiConfigsManager: ApiConfigsManager, + appPreferencesStore: AppPreferencesStore, ) : CardSdkProvider, CardSdkOwner { private val observer = Observer() @@ -43,10 +59,26 @@ internal class DefaultCardSdkProvider @Inject constructor( override val sdk: TangemSdk get() = holder?.sdk ?: tryToRegisterWithForegroundActivity() + init { + appPreferencesStore.getObjectMap(PreferencesKeys.apiConfigsEnvironmentKey) + .map { it[ApiConfig.ID.TangemCardSdk.name] == ApiEnvironment.PROD } + .onEach { isProd -> + holder?.let { + it.sdk.config.isTangemAttestationProdEnv = isProd + } + } + .launchIn(CoroutineScope(SupervisorJob() + dispatchers.main)) + } + override fun register(activity: FragmentActivity) = runBlocking(dispatchers.mainImmediate) { if (activity.isDestroyed || activity.isFinishing || activity.isChangingConfigurations) { val message = "Tangem SDK owner registration skipped: activity is destroyed or finishing" - analyticsEventHandler.send(TangemSdkWarningEvent(message)) + analyticsExceptionHandler.sendException( + ExceptionAnalyticsEvent( + exception = IllegalStateException(message), + params = errorParams, + ), + ) Log.info { message } return@runBlocking } @@ -64,14 +96,24 @@ internal class DefaultCardSdkProvider @Inject constructor( private fun tryToRegisterWithForegroundActivity(): TangemSdk = runBlocking(dispatchers.mainImmediate) { val warning = "Tangem SDK holder is null, trying to recreate it with foreground activity" - analyticsEventHandler.send(TangemSdkWarningEvent(warning)) + analyticsExceptionHandler.sendException( + ExceptionAnalyticsEvent( + exception = IllegalStateException(warning), + params = errorParams, + ), + ) Log.warning { warning } val activity = foregroundActivityObserver.foregroundActivity if (activity == null) { val error = "Tangem SDK holder is null and foreground activity is null" - analyticsEventHandler.send(TangemSdkWarningEvent(error)) + analyticsExceptionHandler.sendException( + ExceptionAnalyticsEvent( + exception = IllegalStateException(error), + params = errorParams, + ), + ) Log.error { error } error(error) } @@ -82,7 +124,12 @@ internal class DefaultCardSdkProvider @Inject constructor( if (sdk == null) { val error = "Tangem SDK is null after re-registering with foreground activity" - analyticsEventHandler.send(TangemSdkWarningEvent(error)) + analyticsExceptionHandler.sendException( + ExceptionAnalyticsEvent( + exception = IllegalStateException(error), + params = errorParams, + ), + ) Log.error { error } error(error) } @@ -108,7 +155,12 @@ internal class DefaultCardSdkProvider @Inject constructor( authenticationManager = authenticationManager, keystoreManager = keystoreManager, wordlist = Wordlist.getWordlist(activity), - config = config, + config = config.apply { + isNewOnlineAttestationEnabled = cardSdkFeatureToggles.isNewAttestationEnabled + + val apiConfig = apiConfigsManager.getEnvironmentConfig(id = ApiConfig.ID.TangemCardSdk) + isTangemAttestationProdEnv = apiConfig.environment == ApiEnvironment.PROD + }, ) holder = Holder( @@ -161,7 +213,6 @@ internal class DefaultCardSdkProvider @Inject constructor( val config = Config( linkedTerminal = true, - allowUntrustedCards = true, filter = CardFilter( allowedCardTypes = FirmwareVersion.FirmwareType.entries.toList(), maxFirmwareVersion = FirmwareVersion(major = 6, minor = 33), @@ -170,5 +221,10 @@ internal class DefaultCardSdkProvider @Inject constructor( ), ), ) + + val errorParams = mapOf( + "Category" to "Tangem SDK", + "Event" to "Warning", + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt b/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt index 1661cedb1d..9870501655 100644 --- a/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt +++ b/app/src/main/java/com/tangem/tap/data/RuntimeUserWalletsStore.kt @@ -20,11 +20,12 @@ internal class RuntimeUserWalletsStore( override val userWallets: Flow> get() = userWalletsListManager.userWallets - override suspend fun getSyncOrNull(key: UserWalletId): UserWallet? { - return userWalletsListManager - .userWallets - .firstOrNull() - ?.singleOrNull { it.walletId == key } + override fun getSyncOrNull(key: UserWalletId): UserWallet? { + return userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == key } + } + + override suspend fun getSyncStrict(key: UserWalletId): UserWallet { + return requireNotNull(getSyncOrNull(key)) { "Unable to find user wallet with provided ID: $key" } } override suspend fun getAllSyncOrNull(): List? { diff --git a/app/src/main/java/com/tangem/tap/data/TangemSdkWarningEvent.kt b/app/src/main/java/com/tangem/tap/data/TangemSdkWarningEvent.kt deleted file mode 100644 index 1393bfbf57..0000000000 --- a/app/src/main/java/com/tangem/tap/data/TangemSdkWarningEvent.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.tap.data - -import com.tangem.core.analytics.models.AnalyticsEvent - -internal class TangemSdkWarningEvent(message: String) : AnalyticsEvent( - category = "Tangem SDK", - event = "Warning", - error = IllegalStateException(message), -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt index 85d5a6630b..368216c920 100644 --- a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt +++ b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt @@ -2,7 +2,6 @@ package com.tangem.tap.di import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader -import com.tangem.datasource.local.token.ExpressAssetsStore import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.exchange.RampStateManager @@ -55,7 +54,6 @@ internal object ActivityModule { excludedBlockchains: ExcludedBlockchains, dispatchers: CoroutineDispatcherProvider, onrampFeatureToggles: OnrampFeatureToggles, - expressAssetsStore: ExpressAssetsStore, ): RampStateManager { return DefaultRampManager( exchangeService = appStateHolder.exchangeService, @@ -67,7 +65,6 @@ internal object ActivityModule { excludedBlockchains = excludedBlockchains, dispatchers = dispatchers, onrampFeatureToggles = onrampFeatureToggles, - expressAssetsStore = expressAssetsStore, ) } 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 8d32208885..f6467cc073 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 @@ -4,11 +4,14 @@ import com.tangem.domain.card.repository.DerivationsRepository 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.staking.repositories.StakingRepository +import com.tangem.domain.tokens.TokensFeatureToggles 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.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -69,6 +72,8 @@ internal object ManageTokensDomainModule { derivationsRepository: DerivationsRepository, stakingRepository: StakingRepository, quotesRepository: QuotesRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): SaveManagedTokensUseCase { return SaveManagedTokensUseCase( customTokensRepository = customTokensRepository, @@ -78,6 +83,8 @@ internal object ManageTokensDomainModule { derivationsRepository = derivationsRepository, stakingRepository = stakingRepository, quotesRepository = quotesRepository, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, ) } @@ -108,4 +115,12 @@ internal object ManageTokensDomainModule { fun provideCheckCurrencyUnsupportedUseCase(repository: ManageTokensRepository): CheckCurrencyUnsupportedUseCase { return CheckCurrencyUnsupportedUseCase(repository) } + + @Provides + @Singleton + fun provideDistinctManagedCurrenciesTokenUseCase( + coroutineDispatchersProvider: CoroutineDispatcherProvider, + ): GetDistinctManagedCurrenciesUseCase { + return GetDistinctManagedCurrenciesUseCase(coroutineDispatchersProvider) + } } \ No newline at end of file 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 72b1e2451c..3a568db5e6 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 @@ -4,7 +4,9 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains 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.staking.repositories.StakingRepository +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository @@ -60,6 +62,8 @@ object MarketsDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, quotesRepository: QuotesRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): SaveMarketTokensUseCase { return SaveMarketTokensUseCase( derivationsRepository = derivationsRepository, @@ -68,6 +72,8 @@ object MarketsDomainModule { networksRepository = networksRepository, stakingRepository = stakingRepository, quotesRepository = quotesRepository, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + 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 new file mode 100644 index 0000000000..a8cb6b030d --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/NFTDomainModule.kt @@ -0,0 +1,69 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.nft.* +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +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 NFTDomainModule { + + @Provides + @Singleton + fun providesGetNFTCollectionsUseCase( + currenciesRepository: CurrenciesRepository, + nftRepository: NFTRepository, + ): GetNFTCollectionsUseCase = GetNFTCollectionsUseCase( + currenciesRepository = currenciesRepository, + nftRepository = nftRepository, + ) + + @Provides + @Singleton + fun providesFetchNFTCollectionsUseCase( + currenciesRepository: CurrenciesRepository, + nftRepository: NFTRepository, + ): FetchNFTCollectionsUseCase = FetchNFTCollectionsUseCase( + currenciesRepository = currenciesRepository, + nftRepository = nftRepository, + ) + + @Provides + @Singleton + fun providesFetchNFTCollectionAssetsUseCase(nftRepository: NFTRepository): FetchNFTCollectionAssetsUseCase = + FetchNFTCollectionAssetsUseCase( + nftRepository = nftRepository, + ) + + @Provides + @Singleton + fun providesGetNFTAvailableNetworksUseCase( + nftRepository: NFTRepository, + currenciesRepository: CurrenciesRepository, + ): GetNFTAvailableNetworksUseCase = GetNFTAvailableNetworksUseCase( + currenciesRepository = currenciesRepository, + nftRepository = nftRepository, + ) + + @Provides + @Singleton + fun providesFilterNFTAvailableNetworksUseCase( + dispatchers: CoroutineDispatcherProvider, + ): FilterNFTAvailableNetworksUseCase = FilterNFTAvailableNetworksUseCase( + dispatchers = dispatchers, + ) + + @Provides + @Singleton + fun providesGetNFTNetworkStatusUseCase(networksRepository: NetworksRepository): GetNFTNetworkStatusUseCase = + GetNFTNetworkStatusUseCase( + networksRepository = networksRepository, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index 87afb55912..77bd0c1526 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.domain +import com.tangem.domain.onramp.repositories.LegacyTopUpRepository import com.tangem.domain.onramp.* import com.tangem.domain.onramp.repositories.HotCryptoRepository import com.tangem.domain.onramp.repositories.OnrampErrorResolver @@ -242,4 +243,10 @@ internal object OnrampDomainModule { fun provideFetchHotCryptoUseCase(hotCryptoRepository: HotCryptoRepository): FetchHotCryptoUseCase { return FetchHotCryptoUseCase(hotCryptoRepository) } + + @Provides + @Singleton + fun provideGetLegacyTopUpUrlUseCase(legacyTopUpRepository: LegacyTopUpRepository): GetLegacyTopUpUrlUseCase { + return GetLegacyTopUpUrlUseCase(legacyTopUpRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index db77c4ceba..cb6976b9b8 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.staking.* import com.tangem.domain.staking.repositories.* +import com.tangem.domain.walletmanager.WalletManagersFacade import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -209,4 +210,12 @@ internal object StakingDomainModule { fun provideGetStakingIntegrationIdUseCase(stakingRepository: StakingRepository): GetStakingIntegrationIdUseCase { return GetStakingIntegrationIdUseCase(stakingRepository) } + + @Provides + @Singleton + fun provideCheckAccountInitializedUseCase( + walletManagersFacade: WalletManagersFacade, + ): CheckAccountInitializedUseCase { + return CheckAccountInitializedUseCase(walletManagersFacade) + } } \ 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 73ecbf344e..237b83807e 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 @@ -2,13 +2,18 @@ package com.tangem.tap.di.domain import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +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.staking.repositories.StakingRepository import com.tangem.domain.tokens.* -import com.tangem.domain.tokens.operations.* +import com.tangem.domain.tokens.operations.BaseCurrenciesStatusesOperations +import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations +import com.tangem.domain.tokens.operations.CachedCurrenciesStatusesOperations import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.tap.domain.tokens.DefaultTokensFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -29,12 +34,16 @@ internal object TokensDomainModule { networksRepository: NetworksRepository, stakingRepository: StakingRepository, quotesRepository: QuotesRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): AddCryptoCurrenciesUseCase { return AddCryptoCurrenciesUseCase( currenciesRepository = currenciesRepository, networksRepository = networksRepository, stakingRepository = stakingRepository, quotesRepository = quotesRepository, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, ) } @@ -45,8 +54,17 @@ internal object TokensDomainModule { quotesRepository: QuotesRepository, networksRepository: NetworksRepository, stakingRepository: StakingRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): FetchTokenListUseCase { - return FetchTokenListUseCase(currenciesRepository, networksRepository, quotesRepository, stakingRepository) + return FetchTokenListUseCase( + currenciesRepository = currenciesRepository, + networksRepository = networksRepository, + quotesRepository = quotesRepository, + stakingRepository = stakingRepository, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, + ) } @Provides @@ -149,8 +167,17 @@ internal object TokensDomainModule { quotesRepository: QuotesRepository, networksRepository: NetworksRepository, stakingRepository: StakingRepository, + singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): FetchCurrencyStatusUseCase { - return FetchCurrencyStatusUseCase(currenciesRepository, networksRepository, quotesRepository, stakingRepository) + return FetchCurrencyStatusUseCase( + currenciesRepository = currenciesRepository, + networksRepository = networksRepository, + quotesRepository = quotesRepository, + stakingRepository = stakingRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, + ) } @Provides @@ -160,8 +187,17 @@ internal object TokensDomainModule { quotesRepository: QuotesRepository, networksRepository: NetworksRepository, stakingRepository: StakingRepository, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): FetchCardTokenListUseCase { - return FetchCardTokenListUseCase(currenciesRepository, networksRepository, quotesRepository, stakingRepository) + return FetchCardTokenListUseCase( + currenciesRepository = currenciesRepository, + networksRepository = networksRepository, + quotesRepository = quotesRepository, + stakingRepository = stakingRepository, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, + ) } @Provides @@ -209,7 +245,6 @@ internal object TokensDomainModule { currenciesRepository: CurrenciesRepository, stakingRepository: StakingRepository, promoRepository: PromoRepository, - swapFeatureToggles: SwapFeatureToggles, dispatchers: CoroutineDispatcherProvider, currencyStatusOperations: BaseCurrencyStatusOperations, ): GetCryptoCurrencyActionsUseCase { @@ -219,7 +254,6 @@ internal object TokensDomainModule { currenciesRepository = currenciesRepository, stakingRepository = stakingRepository, promoRepository = promoRepository, - swapFeatureToggles = swapFeatureToggles, dispatchers = dispatchers, currencyStatusOperations = currencyStatusOperations, ) @@ -273,9 +307,13 @@ internal object TokensDomainModule { @Singleton fun provideUpdateDelayedCurrencyStatusUseCase( networksRepository: NetworksRepository, + singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): UpdateDelayedNetworkStatusUseCase { return UpdateDelayedNetworkStatusUseCase( networksRepository = networksRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, ) } @@ -350,22 +388,20 @@ internal object TokensDomainModule { quotesRepository: QuotesRepository, networksRepository: NetworksRepository, stakingRepository: StakingRepository, + singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, ): BaseCurrenciesStatusesOperations { - return if (tokensFeatureToggles.isBalancesCachingEnabled) { - CachedCurrenciesStatusesOperations( - currenciesRepository = currenciesRepository, - quotesRepository = quotesRepository, - networksRepository = networksRepository, - stakingRepository = stakingRepository, - ) - } else { - LceCurrenciesStatusesOperations( - currenciesRepository = currenciesRepository, - quotesRepository = quotesRepository, - networksRepository = networksRepository, - stakingRepository = stakingRepository, - ) - } + return CachedCurrenciesStatusesOperations( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + stakingRepository = stakingRepository, + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, + ) } @Provides @@ -376,21 +412,19 @@ internal object TokensDomainModule { quotesRepository: QuotesRepository, networksRepository: NetworksRepository, stakingRepository: StakingRepository, + singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + multiNetworkStatusFetcher: MultiNetworkStatusFetcher, ): BaseCurrencyStatusOperations { - return if (tokensFeatureToggles.isBalancesCachingEnabled) { - CachedCurrenciesStatusesOperations( - currenciesRepository = currenciesRepository, - quotesRepository = quotesRepository, - networksRepository = networksRepository, - stakingRepository = stakingRepository, - ) - } else { - CurrenciesStatusesOperations( - currenciesRepository = currenciesRepository, - quotesRepository = quotesRepository, - networksRepository = networksRepository, - stakingRepository = stakingRepository, - ) - } + return CachedCurrenciesStatusesOperations( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + stakingRepository = stakingRepository, + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, + ) } } \ 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 507a284a76..adc63cbd3d 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 @@ -2,6 +2,9 @@ package com.tangem.tap.di.domain import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.demo.DemoConfig +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.transaction.FeeRepository @@ -33,12 +36,16 @@ internal object TransactionDomainModule { cardSdkConfigRepository: CardSdkConfigRepository, transactionRepository: TransactionRepository, walletManagersFacade: WalletManagersFacade, + singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + tokensFeatureToggles: TokensFeatureToggles, ): SendTransactionUseCase { return SendTransactionUseCase( demoConfig = DemoConfig(), cardSdkConfigRepository = cardSdkConfigRepository, transactionRepository = transactionRepository, walletManagersFacade = walletManagersFacade, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + tokensFeatureToggles = tokensFeatureToggles, ) } @@ -49,12 +56,16 @@ internal object TransactionDomainModule { walletManagersFacade: WalletManagersFacade, currenciesRepository: CurrenciesRepository, networksRepository: NetworksRepository, + singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + tokensFeatureToggles: TokensFeatureToggles, ): AssociateAssetUseCase { return AssociateAssetUseCase( cardSdkConfigRepository = cardSdkConfigRepository, walletManagersFacade = walletManagersFacade, currenciesRepository = currenciesRepository, networksRepository = networksRepository, + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + tokensFeatureToggles = tokensFeatureToggles, ) } 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 49fdf2c1be..9be71f685e 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 @@ -10,7 +10,10 @@ import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletNamesMigrationRepository import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.* +import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase +import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.features.nft.NFTFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -18,6 +21,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +@Suppress("TooManyFunctions") @Module @InstallIn(SingletonComponent::class) internal object WalletsDomainModule { @@ -165,4 +169,22 @@ internal object WalletsDomainModule { fun providesSeedPhraseNotificationUseCase(walletsRepository: WalletsRepository): SeedPhraseNotificationUseCase { return SeedPhraseNotificationUseCase(walletsRepository = walletsRepository) } + + @Provides + @Singleton + fun provideGetCardImageUseCase(onlineCardVerifier: OnlineCardVerifier): GetCardImageUseCase { + return GetCardImageUseCase(verifier = onlineCardVerifier) + } + + @Provides + @Singleton + fun providesIsWalletNFTEnabledSyncUseCase( + walletsRepository: WalletsRepository, + nftFeatureToggles: NFTFeatureToggles, + ): IsWalletNFTEnabledSyncUseCase { + return IsWalletNFTEnabledSyncUseCase( + walletsRepository = walletsRepository, + nftFeatureToggles = nftFeatureToggles, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/routing/AppRouterModule.kt b/app/src/main/java/com/tangem/tap/di/routing/AppRouterModule.kt index 4f4aa7e79b..3bfc0b3a44 100644 --- a/app/src/main/java/com/tangem/tap/di/routing/AppRouterModule.kt +++ b/app/src/main/java/com/tangem/tap/di/routing/AppRouterModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.routing import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.tap.routing.ProxyAppRouter import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.tap.routing.configurator.MutableAppRouterConfig @@ -17,8 +18,15 @@ internal object AppRouterModule { @Provides @Singleton - fun provideAppRouter(config: AppRouterConfig, dispatchers: CoroutineDispatcherProvider): AppRouter = - ProxyAppRouter(config, dispatchers) + fun provideAppRouter( + config: AppRouterConfig, + dispatchers: CoroutineDispatcherProvider, + analyticsExceptionHandler: AnalyticsExceptionHandler, + ): AppRouter = ProxyAppRouter( + config = config, + dispatchers = dispatchers, + analyticsExceptionHandler = analyticsExceptionHandler, + ) @Provides @Singleton 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 9497f54791..7117819925 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,14 +6,17 @@ import com.tangem.domain.common.TwinCardNumber import com.tangem.domain.common.getTwinCardNumber import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.wallets.models.Artwork -import com.tangem.operations.attestation.CardVerifyAndGetInfo import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.operations.attestation.api.models.CardVerifyAndGetInfo fun CardDTO.signedHashesCount(): Int { return wallets.sumOf { it.totalSignedHashes ?: 0 } } -suspend fun CardDTO.getOrLoadCardArtworkUrl(cardInfo: Result? = null): String { +suspend fun CardDTO.getOrLoadCardArtworkUrl( + cardInfo: Result? = null, + onlineCardVerifier: OnlineCardVerifier, +): String { fun ifAnyError(): String { return when { cardId.startsWith(Artwork.SERGIO_CARD_ID) -> Artwork.SERGIO_CARD_URL @@ -28,7 +31,7 @@ suspend fun CardDTO.getOrLoadCardArtworkUrl(cardInfo: Result { val artworkId = cardInfoResult.data.artwork?.id if (artworkId.isNullOrEmpty()) { diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index 27fa8ead9e..6ea8247faf 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -235,13 +235,25 @@ internal class LegacyScanProcessor @Inject constructor( } else { Analytics.setContext(scanResponse) + val isTwinRefactoringEnabled = + store.inject(DaggerGraphState::onboardingV2FeatureToggles).isTwinRefactoringEnabled + val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase).invokeSync() if (scanResponse.twinsIsTwinned() && !wasTwinsOnboardingShown) { onWalletNotCreated() - store.dispatchOnMain(TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly(scanResponse))) - navigateTo(AppRoute.OnboardingTwins) { onProgressStateChange(it) } + if (isTwinRefactoringEnabled) { + navigateTo( + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.WelcomeOnlyTwin, + ), + ) { onProgressStateChange(it) } + } else { + store.dispatchOnMain(TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly(scanResponse))) + navigateTo(AppRoute.OnboardingTwins) { onProgressStateChange(it) } + } } else { delay(DELAY_SDK_DIALOG_CLOSE) onSuccess(scanResponse) diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt index 09fe732b6d..abf3f1f23d 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt @@ -7,9 +7,11 @@ import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.domain.card.ScanCardException import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.redux.StateDialog +import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent import com.tangem.tap.common.extensions.dispatchDialogShow import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.common.extensions.inject @@ -34,7 +36,7 @@ internal object UseCaseScanProcessor { ifLeft = { val error = scanCardExceptionConverter.convertBack(it) - Analytics.send(Basic.ScanError(error)) + Analytics.sendErrorEvent(TangemSdkErrorEvent(error)) CompletionResult.Failure(error) }, ifRight = { CompletionResult.Success(it) }, @@ -97,7 +99,12 @@ internal object UseCaseScanProcessor { -> { val error = scanCardExceptionConverter.convertBack(exception) - Analytics.send(Basic.ScanError(error)) + Analytics.sendException( + ExceptionAnalyticsEvent( + exception = error, + params = mapOf("Event" to "Scan"), + ), + ) onFailure(error) } } diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt index 7bd5572200..b9d6aff5fd 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt @@ -59,15 +59,27 @@ class CheckForOnboardingChain( else -> { Analytics.setContext(previousChainResult) + val isTwinRefactoringEnabled = + store.inject(DaggerGraphState::onboardingV2FeatureToggles).isTwinRefactoringEnabled + val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase) .invokeSync() // If twins was twinned previously but twins welcome not shown if (previousChainResult.twinsIsTwinned() && !wasTwinsOnboardingShown) { - store.dispatchOnMain( - TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly(previousChainResult)), - ) - ScanChainException.OnboardingNeeded(AppRoute.OnboardingTwins).left() + if (isTwinRefactoringEnabled) { + ScanChainException.OnboardingNeeded( + AppRoute.Onboarding( + scanResponse = previousChainResult, + mode = AppRoute.Onboarding.Mode.WelcomeOnlyTwin, + ), + ).left() + } else { + store.dispatchOnMain( + TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly(previousChainResult)), + ) + ScanChainException.OnboardingNeeded(AppRoute.OnboardingTwins).left() + } } else { delay(DELAY_SDK_DIALOG_CLOSE) previousChainResult.right() diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt index 53062e6918..add75d87d0 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt @@ -12,7 +12,7 @@ internal class ScanCardExceptionConverter : TwoWayConverter ScanCardException.UnknownException(value) } - override fun convertBack(value: ScanCardException): TangemError { + override fun convertBack(value: ScanCardException): TangemSdkError { return when (value) { is ScanCardException.UnknownException -> TangemSdkError.ExceptionError(value) is ScanCardException.UserCancelled -> TangemSdkError.UserCancelled() @@ -31,7 +31,7 @@ internal class ScanCardExceptionConverter : TwoWayConverter ScanCardException.UnknownException(value) } - private fun concertScanChainException(value: ScanCardException.ChainException): TangemError { + private fun concertScanChainException(value: ScanCardException.ChainException): TangemSdkError { return when (val e = value as? ScanChainException) { is ScanChainException.DisclaimerWasCanceled -> TangemSdkError.UserCancelled() is ScanChainException.OnboardingNeeded, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index abf3ce3f3c..7ef7432c75 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -14,7 +14,6 @@ import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.services.secure.SecureStorage import com.tangem.common.usersCode.UserCodeRepository import com.tangem.core.analytics.Analytics -import com.tangem.core.analytics.models.Basic import com.tangem.core.res.getStringSafe import com.tangem.crypto.bip39.DefaultMnemonic import com.tangem.crypto.hdWallet.DerivationPath @@ -41,6 +40,7 @@ import com.tangem.sdk.api.CreateProductWalletTaskResponse import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.visa.VisaCardActivationResponse import com.tangem.sdk.api.visa.VisaCardActivationTaskMode +import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent import com.tangem.tap.derivationsFinder import com.tangem.tap.domain.tasks.product.CreateProductWalletTask import com.tangem.tap.domain.tasks.product.ResetBackupCardTask @@ -224,7 +224,7 @@ internal class DefaultTangemSdkManager( private fun sendScanResultsToAnalytics(result: CompletionResult) { if (result is CompletionResult.Failure) { (result.error as? TangemSdkError)?.let { error -> - Analytics.send(Basic.ScanError(error)) + Analytics.sendErrorEvent(TangemSdkErrorEvent(error)) } } } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt index 93a6fcbeaa..9b8fcc752f 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2MockContent.kt @@ -32,6 +32,11 @@ object Wallet2MockContent : MockContent { name = "TANGEM 2.0", publicKey = byteArrayOf(2, -120, 89, -52, -60, 36, -73, -17, 103, 107, -110, -36, 3, 110, -122, 72, 43, -38, 8, 30, -50, 25, -23, -17, 38, 94, 5, -112, -20, 9, 54, -24, -32), ), + manufacturer = Card.Manufacturer( + name = "TANGEM SDK", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), walletCurves = listOf( EllipticCurve.Secp256k1, EllipticCurve.Ed25519, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index 22263344cf..8b3f45673e 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -32,6 +32,11 @@ object WalletMockContent : MockContent { name = "TANGEM AG", publicKey = byteArrayOf(3, 86, -25, -61, 55, 99, 41, -33, -82, 115, -120, -33, 22, -107, 103, 3, -122, 16, 60, -110, 72, 106, -121, 100, 79, -87, -27, 18, -55, -49, 78, -110, -2), ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), walletCurves = listOf(EllipticCurve.Secp256k1, EllipticCurve.Ed25519, EllipticCurve.Bls12381G2Aug), firmwareVersion = FirmwareVersion( major = 4, diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt index 78ea74f626..890ce4ba9b 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCardActivationTask.kt @@ -9,12 +9,14 @@ import com.tangem.common.card.EllipticCurve import com.tangem.common.core.CardSession import com.tangem.common.core.CardSessionRunnable import com.tangem.common.core.CompletionCallback +import com.tangem.common.core.TangemError import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toHexString import com.tangem.common.map import com.tangem.common.timemeasure.RealtimeMonotonicTimeSource -import com.tangem.crypto.CryptoUtils +import com.tangem.core.error.ext.tangemError +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.datasource.local.visa.VisaOTPStorage import com.tangem.datasource.local.visa.VisaOtpData @@ -22,11 +24,14 @@ import com.tangem.datasource.local.visa.hasSavedOTP import com.tangem.domain.common.visa.VisaUtilities import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.visa.error.VisaActivationError +import com.tangem.domain.visa.error.VisaAuthorizationAPIError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.domain.visa.repository.VisaAuthRepository import com.tangem.operations.GenerateOTPCommand import com.tangem.operations.attestation.AttestCardKeyCommand +import com.tangem.operations.derivation.DeriveWalletPublicKeyTask import com.tangem.operations.pins.SetUserCodeCommand import com.tangem.operations.sign.SignHashCommand import com.tangem.operations.sign.SignHashResponse @@ -52,6 +57,8 @@ class VisaCardActivationTask @AssistedInject constructor( private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, ) : CardSessionRunnable { + override val allowsRequestAccessCodeFromRepository: Boolean = false + private class SessionContext( val visaActivationRepository: VisaActivationRepository, val cardId: String, @@ -68,7 +75,7 @@ class VisaCardActivationTask @AssistedInject constructor( val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) if (card.cardId != activationInput.cardId) { - return CompletionResult.Failure(TangemSdkError.Underlying(VisaActivationError.WrongCard.message)) + return CompletionResult.Failure(VisaActivationError.WrongCard.tangemError) } val visaActivationRepository = visaActivationRepositoryFactory.create( @@ -101,7 +108,7 @@ class VisaCardActivationTask @AssistedInject constructor( private suspend fun SessionContext.signAuthorizationChallenge( challengeToSign: VisaAuthChallenge.Card, ): CompletionResult { - val attestationCommand = AttestCardKeyCommand(challenge = CryptoUtils.generateRandomBytes(length = 16)) + val attestationCommand = AttestCardKeyCommand(challenge = challengeToSign.challenge.hexToBytes()) val timedResult = RealtimeMonotonicTimeSource.measureTimedValue { suspendCancellableCoroutine { continuation -> attestationCommand.run(session = session) { attestationResponse -> @@ -149,7 +156,7 @@ class VisaCardActivationTask @AssistedInject constructor( private suspend fun SessionContext.getDataToSign( signedChallenge: VisaAuthSignedChallenge, - ): Either = either { + ): Either = either { catch( block = { val tokens = visaAuthRepository.getAccessTokens(signedChallenge) @@ -158,13 +165,13 @@ class VisaCardActivationTask @AssistedInject constructor( val remoteState = visaActivationRepository.getActivationRemoteState() if (remoteState !is VisaActivationRemoteState.CardWalletSignatureRequired) { - raise(TangemSdkError.Underlying(VisaActivationError.WrongRemoteState.message)) + raise(VisaActivationError.WrongRemoteState.tangemError) } visaActivationRepository.getCardWalletAcceptanceData(remoteState.request) }, catch = { - raise(TangemSdkError.Underlying("Underlying network error: ${it.message ?: ""}")) + raise(VisaAuthorizationAPIError.tangemError) }, ) } @@ -245,11 +252,17 @@ class VisaCardActivationTask @AssistedInject constructor( card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) - val derivedPublicKey = wallet.derivedKeys[VisaUtilities.visaDefaultDerivationPath] - ?: return CompletionResult.Failure(TangemSdkError.Underlying(VisaActivationError.MissingWallet.message)) + val derivedPublicKey = when (val deriveKeyResult = deriveKey(wallet.publicKey)) { + is CompletionResult.Failure -> { + return CompletionResult.Failure(deriveKeyResult.error) + } + is CompletionResult.Success -> { + deriveKeyResult.data + } + } val walletAddress = VisaWalletPublicKeyUtility.generateAddressOnSecp256k1(derivedPublicKey.publicKey) - .getOrElse { return CompletionResult.Failure(TangemSdkError.Underlying(it.message)) } + .getOrElse { return CompletionResult.Failure(it.tangemError) } .value val task = SignHashCommand( @@ -284,6 +297,20 @@ class VisaCardActivationTask @AssistedInject constructor( } } + private suspend fun SessionContext.deriveKey(publicKey: ByteArray): CompletionResult { + val derivationPath = VisaUtilities.visaDefaultDerivationPath + ?: return CompletionResult.Failure(VisaActivationError.FailedToCreateAddress.tangemError) + + val derivationTask = DeriveWalletPublicKeyTask(publicKey, derivationPath) + val derivationTaskResult = suspendCancellableCoroutine { continuation -> + derivationTask.run(session) { result -> + continuation.resume(result) + } + } + + return derivationTaskResult + } + private suspend fun SessionContext.handleSignedData( dataToSign: VisaDataToSignByCardWallet, walletAddress: String, @@ -291,7 +318,7 @@ class VisaCardActivationTask @AssistedInject constructor( ): CompletionResult { val otp = otpStorage.getOTP(cardId) ?: run { createOTP() - otpStorage.getOTP(cardId) ?: return CompletionResult.Failure(TangemSdkError.Underlying("OTP not found")) + otpStorage.getOTP(cardId) ?: return CompletionResult.Failure(VisaActivationError.MissingRootOTP.tangemError) } val signedActivationData = dataToSign.sign( diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt index 778c08b933..18ec47b7fb 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/VisaCustomerWalletApproveTask.kt @@ -11,6 +11,7 @@ import com.tangem.common.core.CompletionCallback import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toHexString +import com.tangem.core.error.ext.tangemError import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.common.util.derivationStyleProvider @@ -18,7 +19,7 @@ import com.tangem.domain.common.visa.VisaUtilities import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility.findKeyWithoutDerivation import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.visa.model.VisaActivationError +import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet import com.tangem.domain.visa.model.sign @@ -37,6 +38,7 @@ class VisaCustomerWalletApproveTask( } if (VisaUtilities.isVisaCard(card.firmwareVersion.doubleValue, card.batchId)) { + // TODO TVF-21 callback(CompletionResult.Failure(TangemSdkError.Underlying("Can't use Visa card for approve"))) return } @@ -44,7 +46,7 @@ class VisaCustomerWalletApproveTask( if (visaDataForApprove.customerWalletCardId != null && card.cardId != visaDataForApprove.customerWalletCardId) { callback( CompletionResult.Failure( - TangemSdkError.Underlying("Use tangem wallet specified during visa registration"), + TangemSdkError.Underlying("Use tangem wallet specified during visa registration"), // TODO TVF-21 ), ) return @@ -74,16 +76,12 @@ class VisaCustomerWalletApproveTask( } val derivationPath = VisaUtilities.visaDefaultDerivationPath(derivationStyle) ?: run { - callback( - CompletionResult.Failure( - TangemSdkError.Underlying("Failed to generate derivation path with provided derivation style"), - ), - ) + callback(CompletionResult.Failure(VisaActivationError.FailedToCreateAddress.tangemError)) return } val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: run { - callback(CompletionResult.Failure(TangemSdkError.Underlying(VisaActivationError.MissingWallet.message))) + callback(CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError)) return } @@ -123,7 +121,7 @@ class VisaCustomerWalletApproveTask( ) validationResult.onLeft { - callback(CompletionResult.Failure(TangemSdkError.Underlying(it.message))) + callback(CompletionResult.Failure(it.tangemError)) return } @@ -144,7 +142,7 @@ class VisaCustomerWalletApproveTask( targetAddress = visaDataForApprove.targetAddress, card = CardDTO(card), ).getOrElse { - callback(CompletionResult.Failure(TangemSdkError.Underlying(it.message))) + callback(CompletionResult.Failure(it.tangemError)) return } 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 0aae1ffb84..2927924f8c 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 @@ -7,6 +7,6 @@ internal class DefaultTokensFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, ) : TokensFeatureToggles { - override val isBalancesCachingEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "BALANCES_CACHING_ENABLED") + override val isNetworksLoadingRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "NETWORKS_LOADING_REFACTORING_ENABLED") } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt index 4b6dd922b9..ad20167f85 100644 --- a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt +++ b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandler.kt @@ -8,11 +8,15 @@ import com.tangem.common.core.CardSession import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toHexString +import com.tangem.core.error.ext.tangemError import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.domain.common.visa.VisaUtilities import com.tangem.domain.common.visa.VisaWalletPublicKeyUtility +import com.tangem.domain.visa.error.VisaActivationError +import com.tangem.domain.visa.error.VisaAuthorizationAPIError +import com.tangem.domain.visa.error.VisaCardScanError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.domain.visa.repository.VisaAuthRepository @@ -71,9 +75,7 @@ internal class VisaCardScanHandler @Inject constructor( val derivationPath = VisaUtilities.visaDefaultDerivationPath ?: run { Timber.e("Failed to create derivation path while first scan") - return CompletionResult.Failure( - TangemSdkError.Underlying(VisaCardScanHandlerError.FailedToCreateDerivationPath.errorDescription), - ) + return CompletionResult.Failure(VisaCardScanError.FailedToCreateDerivationPath.tangemError) } val derivationTask = DeriveWalletPublicKeyTask(wallet.publicKey, derivationPath) @@ -104,32 +106,24 @@ internal class VisaCardScanHandler @Inject constructor( val derivationPath = VisaUtilities.visaDefaultDerivationPath ?: run { Timber.e("Failed to create derivation path while handling wallet authorization") - return CompletionResult.Failure( - TangemSdkError.Underlying(VisaCardScanHandlerError.FailedToCreateDerivationPath.errorDescription), - ) + return CompletionResult.Failure(VisaCardScanError.FailedToCreateDerivationPath.tangemError) } val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) val wallet = card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: run { Timber.e("Failed to find extended public key while handling wallet authorization") - return CompletionResult.Failure( - TangemSdkError.Underlying(VisaCardScanHandlerError.FailedToFindDerivedWalletKey.errorDescription), - ) + return CompletionResult.Failure(VisaCardScanError.FailedToFindWallet.tangemError) } val extendedPublicKey = wallet.derivedKeys[derivationPath] ?: run { Timber.e("Failed to find extended public key while handling wallet authorization") - return CompletionResult.Failure( - TangemSdkError.Underlying(VisaCardScanHandlerError.FailedToFindDerivedWalletKey.errorDescription), - ) + return CompletionResult.Failure(VisaCardScanError.FailedToFindDerivedWalletKey.tangemError) } val walletAddress = VisaWalletPublicKeyUtility.generateAddressOnSecp256k1(extendedPublicKey.publicKey) .getOrElse { - return CompletionResult.Failure( - TangemSdkError.Underlying("Cannot generate address on Visa curve"), - ) + return CompletionResult.Failure(it.tangemError) } Timber.i("Requesting challenge for wallet authorization") @@ -200,7 +194,7 @@ internal class VisaCardScanHandler @Inject constructor( ) }.getOrElse { Timber.e("Failed to get challenge for Card authorization. Plain error: ${it.message}") - return CompletionResult.Failure(TangemSdkError.Underlying(it.message ?: "Unknown error")) + return CompletionResult.Failure(VisaAuthorizationAPIError.tangemError) } Timber.i("Received challenge to sign: ${challengeResponse.challenge}") @@ -230,11 +224,7 @@ internal class VisaCardScanHandler @Inject constructor( ) }.getOrElse { Timber.e("Failed to sign challenge with Card public key. Plain error: ${it.message}") - return CompletionResult.Failure( - TangemSdkError.Underlying( - customMessage = it.message ?: "Unknown error", - ), - ) + return CompletionResult.Failure(VisaAuthorizationAPIError.tangemError) } visaAuthTokenStorage.store( @@ -251,7 +241,7 @@ internal class VisaCardScanHandler @Inject constructor( } if (error != null) { - return CompletionResult.Failure(TangemSdkError.Underlying(error.message)) + return CompletionResult.Failure(error.tangemError) } val activationInput = VisaActivationInput( diff --git a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandlerError.kt b/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandlerError.kt deleted file mode 100644 index d507ac7b14..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/visa/VisaCardScanHandlerError.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.tap.domain.visa - -private const val COMMON_DESCRIPTION = "Error occurred. Please contact support." - -internal enum class VisaCardScanHandlerError(val errorDescription: String) { - FailedToCreateDerivationPath(COMMON_DESCRIPTION), - FailedToFindWallet(COMMON_DESCRIPTION), - FailedToFindDerivedWalletKey(COMMON_DESCRIPTION), -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt index 07700916ac..b0e2c44579 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/app/TangemWcBlockchainHelper.kt @@ -4,18 +4,11 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.tap.domain.walletconnect2.domain.WcBlockchainHelper -import com.tangem.tap.domain.walletconnect2.domain.models.Account -import com.tangem.tap.domain.walletconnect2.toggles.WalletConnectFeatureToggles +import com.tangem.domain.walletconnect.model.legacy.Account -internal class TangemWcBlockchainHelper( - featureToggles: WalletConnectFeatureToggles, -) : WcBlockchainHelper { +internal class TangemWcBlockchainHelper : WcBlockchainHelper { - private val supportedNonEvmBlockchains = if (featureToggles.isSolanaTxSignEnabled) { - setOf(Blockchain.Solana, Blockchain.SolanaTestnet) - } else { - emptySet() - } + private val supportedNonEvmBlockchains = setOf(Blockchain.Solana, Blockchain.SolanaTestnet) override fun chainIdToNetworkIdOrNull(chainId: String): String? { val parsedId = chainId.parseId() ?: return null diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt index ff5677c84e..5fe143f6c7 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultLegacyWalletConnectRepository.kt @@ -8,6 +8,8 @@ import com.reown.android.relay.ConnectionType import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.walletconnect.pair.unsupportedDApps +import com.tangem.domain.walletconnect.model.legacy.Account import com.tangem.tap.common.analytics.events.WalletConnect import com.tangem.tap.domain.walletconnect2.app.TangemWcBlockchainHelper import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository @@ -15,7 +17,6 @@ import com.tangem.tap.domain.walletconnect2.domain.WcJrpcMethods import com.tangem.tap.domain.walletconnect2.domain.WcJrpcRequestsDeserializer import com.tangem.tap.domain.walletconnect2.domain.WcRequest import com.tangem.tap.domain.walletconnect2.domain.models.* -import com.tangem.tap.domain.walletconnect2.toggles.WalletConnectFeatureToggles import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.OpenSession.SourceType import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow @@ -27,7 +28,6 @@ internal class DefaultLegacyWalletConnectRepository( private val application: Application, private val wcRequestDeserializer: WcJrpcRequestsDeserializer, private val analyticsHandler: AnalyticsEventHandler, - private val walletConnectFeatureToggles: WalletConnectFeatureToggles, ) : LegacyWalletConnectRepository { private var sessionProposal: Wallet.Model.SessionProposal? = null @@ -39,7 +39,7 @@ internal class DefaultLegacyWalletConnectRepository( private val _activeSessions: MutableSharedFlow> = MutableSharedFlow() override val activeSessions: Flow> = _activeSessions - private val blockchainHelper by lazy { TangemWcBlockchainHelper(walletConnectFeatureToggles) } + private val blockchainHelper by lazy { TangemWcBlockchainHelper() } override var currentSessions: List = emptyList() private set @@ -573,9 +573,4 @@ internal class DefaultLegacyWalletConnectRepository( val userChains = userNamespaces.flatMap { it.value.map { account -> account.chainId } } return wcProvidedChains.intersect(userChains.toSet()) } - - private companion object { - - val unsupportedDApps = listOf("dYdX", "dYdX v4", "Apex Pro", "The Sandbox") - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt index 77b531934a..108492f2de 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/data/DefaultWalletConnectSessionsRepository.kt @@ -4,8 +4,8 @@ import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.tangem.datasource.files.FileReader -import com.tangem.tap.domain.walletconnect2.domain.WalletConnectSessionsRepository -import com.tangem.tap.domain.walletconnect2.domain.models.Session +import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository +import com.tangem.domain.walletconnect.model.legacy.Session import timber.log.Timber internal class DefaultWalletConnectSessionsRepository( diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt index 80319c8ce3..3e47ce122d 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/di/WalletConnectInteractorModule.kt @@ -7,6 +7,7 @@ import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.datasource.di.SdkMoshi import com.tangem.datasource.files.FileReader import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper @@ -33,7 +34,6 @@ internal object WalletConnectInteractorModule { fun provideWalletConnectInteractor( wcRepository: LegacyWalletConnectRepository, wcSessionsRepository: WalletConnectSessionsRepository, - walletConnectFeatureToggles: WalletConnectFeatureToggles, currenciesRepository: CurrenciesRepository, walletManagersFacade: WalletManagersFacade, userWalletsListManager: UserWalletsListManager, @@ -44,7 +44,7 @@ internal object WalletConnectInteractorModule { walletConnectRepository = wcRepository, sessionsRepository = wcSessionsRepository, sdkHelper = WalletConnectSdkHelper(), - blockchainHelper = TangemWcBlockchainHelper(walletConnectFeatureToggles), + blockchainHelper = TangemWcBlockchainHelper(), currenciesRepository = currenciesRepository, walletManagersFacade = walletManagersFacade, userWalletsListManager = userWalletsListManager, @@ -69,13 +69,11 @@ internal object WalletConnectModule { application: Application, wcRequestDeserializer: WcJrpcRequestsDeserializer, analyticsHandler: AnalyticsEventHandler, - walletConnectFeatureToggles: WalletConnectFeatureToggles, ): LegacyWalletConnectRepository { return DefaultLegacyWalletConnectRepository( application = application, wcRequestDeserializer = wcRequestDeserializer, analyticsHandler = analyticsHandler, - walletConnectFeatureToggles = walletConnectFeatureToggles, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt index 289a2664b5..59d5954e00 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/LegacyWalletConnectRepository.kt @@ -1,5 +1,6 @@ package com.tangem.tap.domain.walletconnect2.domain +import com.tangem.domain.walletconnect.model.legacy.Account import com.tangem.tap.domain.walletconnect2.domain.models.* import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction.OpenSession.SourceType import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt index 2ba6a15344..d8d5a989ae 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectInteractor.kt @@ -6,6 +6,9 @@ import com.tangem.blockchainsdk.utils.toNetworkId 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.legacy.Account +import com.tangem.domain.walletconnect.model.legacy.Session +import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.models.UserWallet @@ -368,10 +371,6 @@ class WalletConnectInteractor( } } - fun isWalletConnectUri(uri: String): Boolean { - return uri.lowercase().startsWith(WC_SCHEME) - } - /** * Handles Wallet Connect deep links. * If wallet connect is able to handle the deeplink, session is started with deeplink. @@ -436,7 +435,6 @@ class WalletConnectInteractor( } private companion object { - const val WC_SCHEME = "wc" const val WC_TOPIC_QUERY_NAME = "sessionTopic" const val WC_PARAM_REGEX = "([a-zA-Z\\d-]+)=([a-zA-Z\\d]+)" } diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt index 91771ac6e1..74241e4cf6 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcBlockchainHelper.kt @@ -1,7 +1,7 @@ package com.tangem.tap.domain.walletconnect2.domain import com.tangem.blockchain.common.Blockchain -import com.tangem.tap.domain.walletconnect2.domain.models.Account +import com.tangem.domain.walletconnect.model.legacy.Account interface WcBlockchainHelper { fun chainIdToNetworkIdOrNull(chainId: String): String? diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt index 909120438b..16eaf90e60 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WcSessionRequestConverter.kt @@ -1,5 +1,6 @@ package com.tangem.tap.domain.walletconnect2.domain +import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.tap.domain.walletconnect.WalletConnectSdkHelper import com.tangem.tap.domain.walletconnect2.domain.models.BnbData import com.tangem.tap.domain.walletconnect2.domain.models.EthTransactionData diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt index 85e6e06f5b..1298ede44d 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/WalletConnectEvents.kt @@ -1,5 +1,6 @@ package com.tangem.tap.domain.walletconnect2.domain.models +import com.tangem.domain.walletconnect.model.legacy.Account import com.tangem.tap.domain.walletconnect2.domain.WcRequest import java.net.URI diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/WalletConnectFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/WalletConnectFeatureToggles.kt index a6da04800a..48b84481db 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/WalletConnectFeatureToggles.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect2/toggles/WalletConnectFeatureToggles.kt @@ -6,6 +6,6 @@ internal class WalletConnectFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, ) { - val isSolanaTxSignEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "WC_SOLANA_TX_SIGN_ENABLED") + val isRedesignedWalletConnectEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("WALLET_CONNECT_REDESIGN_ENABLED") } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt index 2fb10581de..7391c77f5b 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectAction.kt @@ -9,9 +9,7 @@ import org.rekotlin.Action sealed class WalletConnectAction : Action { data class HandleDeepLink(val wcUri: String?) : WalletConnectAction() - data class StartWalletConnect( - val copiedUri: String?, - ) : WalletConnectAction() + data object StartWalletConnect : WalletConnectAction() data class OpenSession( val wcUri: String, @@ -24,7 +22,6 @@ sealed class WalletConnectAction : Action { data class RejectRequest(val topic: String, val id: Long) : WalletConnectAction() - data class ShowClipboardOrScanQrDialog(val wcUri: String) : WalletConnectAction() //region WalletConnect 2.0 data class ApproveProposal(val proposal: WalletConnectEvents.SessionProposal) : WalletConnectAction() data object RejectProposal : WalletConnectAction() diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt index 8535bc008f..6bbd548de4 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectMiddleware.kt @@ -3,7 +3,6 @@ package com.tangem.tap.features.details.redux.walletconnect import com.tangem.blockchain.common.WalletManager import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.common.routing.AppRoute -import com.tangem.domain.qrscanning.models.SourceType import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.inject @@ -57,22 +56,10 @@ class WalletConnectMiddleware { walletConnectInteractor.disconnectSession(action.topic) } is WalletConnectAction.StartWalletConnect -> { - val uri = action.copiedUri - if (uri != null && isWalletConnectUri(uri)) { - store.dispatchOnMain(WalletConnectAction.ShowClipboardOrScanQrDialog(uri)) - } else { - store.dispatchNavigationAction { push(AppRoute.QrScanning(SourceType.WALLET_CONNECT)) } + store.dispatchNavigationAction { + push(AppRoute.QrScanning(AppRoute.QrScanning.Source.WalletConnect)) } } - is WalletConnectAction.ShowClipboardOrScanQrDialog -> { - store.dispatchOnMain( - GlobalAction.ShowDialog( - WalletConnectDialog.ClipboardOrScanQr( - action.wcUri, - ), - ), - ) - } is WalletConnectAction.OpenSession -> { val index = action.wcUri.indexOf("@") when (action.wcUri[index + 1]) { @@ -197,8 +184,4 @@ class WalletConnectMiddleware { return walletManagerFacade.getStoredWalletManagers(userWallet.walletId) } - - private fun isWalletConnectUri(uri: String): Boolean { - return walletConnectInteractor.isWalletConnectUri(uri) - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt index 9eb02ddd35..ac28ca0b95 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/walletconnect/WalletConnectState.kt @@ -83,7 +83,6 @@ data class WalletForSession( sealed class WalletConnectDialog : StateDialog { data object UnsupportedWcVersion : WalletConnectDialog() - data class ClipboardOrScanQr(val clipboardUri: String) : WalletConnectDialog() data object UnsupportedCard : WalletConnectDialog() data class UnsupportedNetwork(val networks: List? = null) : WalletConnectDialog() data object UnsupportedDapp : WalletConnectDialog() diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt index 773aa32d80..894ab1be67 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt @@ -20,6 +20,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings @@ -55,6 +56,7 @@ internal class CardSettingsModel @Inject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val cardSdkConfigRepository: CardSdkConfigRepository, private val settingsRepository: SettingsRepository, + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -198,13 +200,24 @@ internal class CardSettingsModel @Inject constructor( } if (scanResponse.cardTypesResolver.isTangemTwins()) { - // needs to prepare twin state if it's twin cards (depends on onboarding refactoring) - store.dispatch(TwinCardsAction.IfTwinsPrepareState(scanResponse)) - store.dispatch(TwinCardsAction.SetMode(CreateTwinWalletMode.RecreateWallet(scanResponse))) + if (onboardingV2FeatureToggles.isTwinRefactoringEnabled) { + store.dispatchNavigationAction { + push( + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.RecreateWalletTwin, + ), + ) + } + } else { + // needs to prepare twin state if it's twin cards (depends on onboarding refactoring) + store.dispatch(TwinCardsAction.IfTwinsPrepareState(scanResponse)) + store.dispatch(TwinCardsAction.SetMode(CreateTwinWalletMode.RecreateWallet(scanResponse))) - cardSettingsInteractor.clear() + cardSettingsInteractor.clear() - store.dispatchNavigationAction { push(AppRoute.OnboardingTwins) } + store.dispatchNavigationAction { push(AppRoute.OnboardingTwins) } + } } else { val card = scanResponse.card diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt index f68ae0c367..dd3093deb9 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/ResetCardScreen.kt @@ -140,9 +140,9 @@ private fun ConditionCheckBox(checkedState: Boolean, onCheckedChange: (Boolean) Icon( painter = painterResource( if (checked) { - R.drawable.ic_accepted + R.drawable.ic_accepted_20 } else { - R.drawable.ic_unticked + R.drawable.ic_unticked_20 }, ), contentDescription = null, diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt index 39ae0e13fb..8f4cf86296 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt @@ -4,13 +4,15 @@ import androidx.compose.runtime.Stable import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError import com.tangem.common.routing.AppRouter -import com.tangem.core.analytics.Analytics +import com.tangem.core.analytics.api.AnalyticsErrorHandler +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.common.util.cardTypesResolver import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings +import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.redux.SecurityOption import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor @@ -30,6 +32,8 @@ internal class SecurityModeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val cardSettingsInteractor: CardSettingsInteractor, + private val analyticsEventHandler: AnalyticsEventHandler, + private val analyticsErrorHandler: AnalyticsErrorHandler, ) : Model() { private val scannedScanResponse = cardSettingsInteractor.scannedScanResponse.value @@ -85,14 +89,14 @@ internal class SecurityModeModel @Inject constructor( val paramValue = AnalyticsParam.SecurityMode.from(selectedOption) when (result) { is CompletionResult.Success -> { - Analytics.send(Settings.CardSettings.SecurityModeChanged(paramValue)) + analyticsEventHandler.send(Settings.CardSettings.SecurityModeChanged(paramValue)) store.dispatchNavigationAction(AppRouter::pop) } is CompletionResult.Failure -> { val error = result.error if (error is TangemSdkError && error !is TangemSdkError.UserCancelled) { - Analytics.send(Settings.CardSettings.SecurityModeChanged(paramValue, error)) + analyticsErrorHandler.sendErrorEvent(TangemSdkErrorEvent(error)) } } else -> Unit diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt index b940ddcc29..21deae59a7 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/WalletConnectModel.kt @@ -4,7 +4,6 @@ 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.ui.clipboard.ClipboardManager import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction @@ -23,7 +22,6 @@ import javax.inject.Inject internal class WalletConnectModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val listenToQrScanningUseCase: ListenToQrScanningUseCase, - private val clipboardManager: ClipboardManager, ) : Model() { init { @@ -43,9 +41,7 @@ internal class WalletConnectModel @Inject constructor( isLoading = state.loading, onRemoveSession = { sessionUri -> onRemoveSession(sessionUri, sessions) }, onAddSession = { - store.dispatch( - WalletConnectAction.StartWalletConnect(copiedUri = clipboardManager.getText()), - ) + store.dispatch(WalletConnectAction.StartWalletConnect) }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/ClipboardOrScanQrDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/ClipboardOrScanQrDialog.kt deleted file mode 100644 index 441777794f..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/walletconnect/dialogs/ClipboardOrScanQrDialog.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.tangem.tap.features.details.ui.walletconnect.dialogs - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.tangem.common.routing.AppRoute -import com.tangem.domain.qrscanning.models.SourceType -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction -import com.tangem.tap.store -import com.tangem.wallet.R - -object ClipboardOrScanQrDialog { - fun create(wcUri: String, context: Context): AlertDialog { - return MaterialAlertDialogBuilder(context, R.style.CustomMaterialDialog).apply { - setTitle(context.getString(R.string.common_select_action)) - setMessage(context.getText(R.string.wallet_connect_clipboard_alert)) - setPositiveButton(context.getText(R.string.wallet_connect_paste_from_clipboard)) { _, _ -> - store.dispatch( - WalletConnectAction.OpenSession(wcUri, WalletConnectAction.OpenSession.SourceType.CLIPBOARD), - ) - } - setNegativeButton(context.getText(R.string.wallet_connect_scan_new_code)) { _, _ -> - store.dispatchNavigationAction { - push(AppRoute.QrScanning(source = SourceType.WALLET_CONNECT)) - } - } - setOnDismissListener { - store.dispatch(GlobalAction.HideDialog) - } - }.create() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt b/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt index 5230ab1717..58f8a67687 100644 --- a/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt +++ b/app/src/main/java/com/tangem/tap/features/home/HomeModel.kt @@ -19,7 +19,6 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.tokens.TokensAction import com.tangem.domain.wallets.builder.UserWalletBuilder -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter import com.tangem.tap.common.analytics.events.IntroductionProcess @@ -44,12 +43,12 @@ import javax.inject.Inject internal class HomeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val scanCardProcessor: ScanCardProcessor, - private val generateWalletNameUseCase: GenerateWalletNameUseCase, private val saveWalletUseCase: SaveWalletUseCase, private val cardSdkConfigRepository: CardSdkConfigRepository, private val settingsRepository: SettingsRepository, private val urlOpener: UrlOpener, private val analyticsEventHandler: AnalyticsEventHandler, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, ) : Model() { private val tangemErrorHandler = TangemTangemErrorsHandler(store) @@ -100,10 +99,7 @@ internal class HomeModel @Inject constructor( } private suspend fun proceedWithScanResponse(scanResponse: ScanResponse) { - val userWallet = UserWalletBuilder( - scanResponse = scanResponse, - generateWalletNameUseCase = generateWalletNameUseCase, - ).build() + val userWallet = userWalletBuilderFactory.create(scanResponse = scanResponse).build() if (userWallet == null) { Timber.e("User wallet not created") diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt index b16af4eab3..ccae0217e2 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt @@ -10,7 +10,6 @@ import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter import com.tangem.tap.common.analytics.events.IntroductionProcess import com.tangem.tap.common.extensions.dispatchNavigationAction @@ -91,8 +90,9 @@ private suspend fun readCard() { } private fun proceedWithScanResponse(scanResponse: ScanResponse) = scope.launch { - val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase) - val userWallet = UserWalletBuilder(scanResponse, walletNameGenerateUseCase).build().guard { + val userWalletBuilder = store.inject(DaggerGraphState::userWalletBuilderFactory).create(scanResponse) + + val userWallet = userWalletBuilder.build().guard { Timber.e("User wallet not created") return@launch } diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 143a8d92a4..e6a4d37f48 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -30,8 +30,6 @@ import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase import com.tangem.domain.staking.FetchStakingTokensUseCase import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.feature.swap.analytics.StoriesEvents -import com.tangem.features.onramp.OnrampFeatureToggles -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.tap.common.extensions.setContext import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.lifecycle.HiltViewModel @@ -60,8 +58,6 @@ internal class MainViewModel @Inject constructor( private val getStoryContentUseCase: GetStoryContentUseCase, private val imagePreloader: ImagePreloader, private val fetchHotCryptoUseCase: FetchHotCryptoUseCase, - private val swapFeatureToggles: SwapFeatureToggles, - onrampFeatureToggles: OnrampFeatureToggles, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, ) : ViewModel() { @@ -82,9 +78,7 @@ internal class MainViewModel @Inject constructor( } } - if (onrampFeatureToggles.isHotTokensEnabled) { - viewModelScope.launch { fetchHotCryptoUseCase() } - } + viewModelScope.launch { fetchHotCryptoUseCase() } updateAppCurrencies() observeFlips() @@ -130,7 +124,7 @@ internal class MainViewModel @Inject constructor( private fun fetchStakingTokens() { viewModelScope.launch(dispatchers.main) { - fetchStakingTokensUseCase(true) + fetchStakingTokensUseCase() .onLeft { Timber.e(it.toString(), "Unable to fetch the staking tokens list") } .onRight { Timber.d("Staking token list was fetched successfully") } } @@ -301,23 +295,21 @@ internal class MainViewModel @Inject constructor( private fun preloadImages() { try { viewModelScope.launch { - if (swapFeatureToggles.isPromoStoriesEnabled) { - val swapStories = getStoryContentUseCase.invokeSync( - id = StoryContentIds.STORY_FIRST_TIME_SWAP.id, - refresh = true, - ).getOrNull() + val swapStories = getStoryContentUseCase.invokeSync( + id = StoryContentIds.STORY_FIRST_TIME_SWAP.id, + refresh = true, + ).getOrNull() - if (swapStories == null) { - analyticsEventHandler.send( - StoriesEvents.Error( - type = StoryContentIds.STORY_FIRST_TIME_SWAP.analyticType, - ), - ) - } else { - val storiesImages = swapStories.getImageUrls() - // try to preload images for stories - storiesImages.forEach(imagePreloader::preload) - } + if (swapStories == null) { + analyticsEventHandler.send( + StoriesEvents.Error( + type = StoryContentIds.STORY_FIRST_TIME_SWAP.analyticType, + ), + ) + } else { + val storiesImages = swapStories.getImageUrls() + // try to preload images for stories + storiesImages.forEach(imagePreloader::preload) } } } catch (ex: Exception) { diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt index b7642d1a29..7eda3eb6c5 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt @@ -14,7 +14,6 @@ import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.visa.model.VisaCardActivationStatus -import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.models.UserWallet import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter @@ -70,6 +69,7 @@ object OnboardingHelper { } } + @Suppress("CyclomaticComplexMethod") fun whereToNavigate(scanResponse: ScanResponse): AppRoute { val newOnboardingSupportTypes = scanResponse.productType == ProductType.Wallet2 || scanResponse.productType == ProductType.Ring || @@ -85,7 +85,7 @@ object OnboardingHelper { scanResponse = scanResponse, mode = when (backupState.startedSource) { BackupStartedSource.Onboarding -> AppRoute.Onboarding.Mode.Onboarding - BackupStartedSource.CreateBackup -> AppRoute.Onboarding.Mode.AddBackup + BackupStartedSource.CreateBackup -> AppRoute.Onboarding.Mode.AddBackupWallet1 }, ) } @@ -104,7 +104,11 @@ object OnboardingHelper { } else { AppRoute.OnboardingOther } - ProductType.Twins -> AppRoute.OnboardingTwins + ProductType.Twins -> if (featureToggles.isTwinRefactoringEnabled) { + AppRoute.Onboarding(scanResponse = scanResponse) + } else { + AppRoute.OnboardingTwins + } ProductType.Start2Coin, ProductType.Visa, -> throw UnsupportedOperationException("Onboarding for ${type.name} cards is not supported") @@ -250,8 +254,9 @@ object OnboardingHelper { hasBackupError: Boolean, alreadyCreatedWallet: UserWallet? = null, ) { - val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase) - val userWallet = alreadyCreatedWallet ?: UserWalletBuilder(scanResponse, walletNameGenerateUseCase) + val userWalletBuilderFactory = store.inject(DaggerGraphState::userWalletBuilderFactory) + + val userWallet = alreadyCreatedWallet ?: userWalletBuilderFactory.create(scanResponse) .hasBackupError(hasBackupError) .backupCardsIds(backupCardsIds?.toSet()) .build() diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingManager.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingManager.kt index a0db9b19f8..e982cd5f2e 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingManager.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingManager.kt @@ -7,8 +7,8 @@ import com.tangem.common.extensions.isZero import com.tangem.common.services.Result import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse -import com.tangem.operations.attestation.CardVerifyAndGetInfo import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.operations.attestation.api.models.CardVerifyAndGetInfo import com.tangem.tap.common.entities.ProgressState import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.safeUpdate @@ -26,16 +26,22 @@ import java.math.BigDecimal /** [REDACTED_AUTHOR] */ +@Deprecated("Remove when navigation refactoring will be implemented") class OnboardingManager(var scanResponse: ScanResponse) { - private val cardRepository: CardRepository = store.inject(DaggerGraphState::cardRepository) + private val cardRepository: CardRepository by lazy { store.inject(DaggerGraphState::cardRepository) } + + private val onlineCardVerifier: OnlineCardVerifier by lazy { store.inject(DaggerGraphState::onlineCardVerifier) } + private var cardInfo: Result? = null suspend fun loadArtworkUrl(): String { val cardInfo = cardInfo - ?: OnlineCardVerifier().getCardInfo(scanResponse.card.cardId, scanResponse.card.cardPublicKey) + ?: onlineCardVerifier.getCardInfo(scanResponse.card.cardId, scanResponse.card.cardPublicKey) + this.cardInfo = cardInfo - return scanResponse.card.getOrLoadCardArtworkUrl(cardInfo) + + return scanResponse.card.getOrLoadCardArtworkUrl(cardInfo, onlineCardVerifier) } suspend fun updateBalance(walletManager: WalletManager): OnboardingWalletBalance { diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/note/OnboardingNoteFragment.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/note/OnboardingNoteFragment.kt index 97746d2d70..8346f444cd 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/note/OnboardingNoteFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/note/OnboardingNoteFragment.kt @@ -26,9 +26,7 @@ import com.tangem.tap.store import com.tangem.utils.extensions.stripZeroPlainString import com.tangem.wallet.R -/** -[REDACTED_AUTHOR] - */ +@Deprecated("Use OnboardingNoteComponent") class OnboardingNoteFragment : BaseOnboardingFragment() { private var previousStep: OnboardingNoteStep = OnboardingNoteStep.None 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 c8f16739b1..3fefa3a904 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 @@ -1,7 +1,6 @@ package com.tangem.tap.features.onboarding.products.wallet.redux import android.net.Uri -import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.common.CompletionResult import com.tangem.common.card.Card import com.tangem.common.core.TangemSdkError @@ -12,6 +11,7 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.services.Result import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.toWrappedList @@ -24,7 +24,6 @@ import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_IDS import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_PREFIX import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.models.Artwork @@ -243,7 +242,9 @@ private suspend fun readCard(): CompletionResult { } private suspend fun loadArtworkForCard(cardId: String, cardPublicKey: ByteArray, defaultArtwork: Uri?): Uri { - return when (val cardInfo = OnlineCardVerifier().getCardInfo(cardId, cardPublicKey)) { + val onlineCardVerifier = store.inject(DaggerGraphState::onlineCardVerifier) + + return when (val cardInfo = onlineCardVerifier.getCardInfo(cardId, cardPublicKey)) { is Result.Success -> { val artworkId = cardInfo.data.artwork?.id if (artworkId.isNullOrEmpty()) { @@ -453,8 +454,6 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) store.dispatchOnMain(BackupAction.AddBackupCard.Success(result.data)) } is CompletionResult.Failure -> { - val crashlytics = FirebaseCrashlytics.getInstance() - when (val error = result.error) { is TangemSdkError.CardVerificationFailed -> { Analytics.send( @@ -491,7 +490,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) GlobalAction.ShowDialog(BackupDialog.AttestationFailed), ) } - else -> crashlytics.recordException(error) + else -> Analytics.sendException(ExceptionAnalyticsEvent(error)) } } } @@ -787,9 +786,10 @@ private suspend fun updateWallet( } private suspend fun createUserWallet(scanResponse: ScanResponse, backupState: BackupState): UserWallet { - val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase) + val userWalletBuilder = store.inject(DaggerGraphState::userWalletBuilderFactory).create(scanResponse) + return requireNotNull( - value = UserWalletBuilder(scanResponse, walletNameGenerateUseCase) + value = userWalletBuilder .backupCardsIds(backupState.backupCardIds.toSet()) .hasBackupError(backupState.hasBackupError) .build(), diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/UnfinishedBackupFoundDialog.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/UnfinishedBackupFoundDialog.kt index ec80c29c24..fbeff5f6f4 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/UnfinishedBackupFoundDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/UnfinishedBackupFoundDialog.kt @@ -5,7 +5,7 @@ import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.tangem.core.analytics.Analytics import com.tangem.domain.models.scan.ScanResponse -import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.features.onboarding.products.wallet.redux.BackupAction import com.tangem.tap.features.onboarding.products.wallet.redux.BackupDialog diff --git a/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletMiddleware.kt b/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletMiddleware.kt index 02fb7c271c..d8f15fd3d7 100644 --- a/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletMiddleware.kt @@ -6,7 +6,6 @@ import com.tangem.common.extensions.guard import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent -import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.models.UserWallet import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Onboarding @@ -70,8 +69,10 @@ internal class SaveWalletMiddleware { scope.launch { val backupInfo = state.backupInfo ?: error("Backup info is null") - val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase) - val userWallet = UserWalletBuilder(backupInfo.scanResponse, walletNameGenerateUseCase) + val userWalletBuilder = store.inject(DaggerGraphState::userWalletBuilderFactory) + .create(scanResponse = backupInfo.scanResponse) + + val userWallet = userWalletBuilder .backupCardsIds(state.backupInfo.backupCardsIds) .hasBackupError(hasBackupError) .build() diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt index 2c0548f38d..26275a69eb 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt @@ -11,10 +11,8 @@ import com.tangem.common.routing.utils.popTo import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic - import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType import com.tangem.domain.wallets.legacy.unlockIfLockable import com.tangem.tap.backupService @@ -119,9 +117,9 @@ internal class WelcomeMiddleware { ) scanCardInternal { scanResponse -> - val walletNameGenerateUseCase = store.inject(DaggerGraphState::generateWalletNameUseCase) - val userWallet = UserWalletBuilder(scanResponse, walletNameGenerateUseCase).build() - ?: return@scanCardInternal + val userWalletBuilder = store.inject(DaggerGraphState::userWalletBuilderFactory).create(scanResponse) + + val userWallet = userWalletBuilder.build() ?: return@scanCardInternal val userWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) userWalletsListManager.save(userWallet, canOverride = true) diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt index d53b637ce1..2671d88af8 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt @@ -9,9 +9,8 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader -import com.tangem.datasource.local.token.ExpressAssetsStore import com.tangem.domain.core.lce.Lce -import com.tangem.domain.exchange.ExchangeableState +import com.tangem.domain.exchange.ExpressAvailabilityState import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase @@ -27,7 +26,6 @@ import com.tangem.utils.coroutines.runCatching import com.tangem.utils.isNullOrZero import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.withContext @Suppress("LongParameterList") internal class DefaultRampManager( @@ -39,7 +37,6 @@ internal class DefaultRampManager( private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase, private val dispatchers: CoroutineDispatcherProvider, private val onrampFeatureToggles: OnrampFeatureToggles, - private val expressAssetsStore: ExpressAssetsStore, excludedBlockchains: ExcludedBlockchains, ) : RampStateManager { @@ -49,19 +46,12 @@ internal class DefaultRampManager( scanResponse: ScanResponse, userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - ): Boolean { - return runCatching { - when { - onrampFeatureToggles.isFeatureEnabled -> getOnrampAvailable(userWalletId, cryptoCurrency) - exchangeService != null -> exchangeService.availableForBuy( - scanResponse = scanResponse, - currency = cryptoCurrencyConverter.convertBack(cryptoCurrency), - ) - else -> false - } - } - .getOrNull() - ?: false + ): ScenarioUnavailabilityReason { + val availabilityState = runCatching { + getOnrampAvailableState(scanResponse, userWalletId, cryptoCurrency) + }.getOrNull() ?: ExpressAvailabilityState.Error + + return availabilityState.toReason(cryptoCurrency.name) } override suspend fun availableForSell( @@ -103,8 +93,11 @@ internal class DefaultRampManager( override suspend fun availableForSwap( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - ): ExchangeableState { - return runCatching { getExchangeableState(userWalletId, cryptoCurrency) }.getOrNull() ?: ExchangeableState.Error + ): ScenarioUnavailabilityReason { + val availabilityState = runCatching { + getExchangeableState(userWalletId, cryptoCurrency) + }.getOrNull() ?: ExpressAvailabilityState.Error + return availabilityState.toReason(cryptoCurrency.name) } override fun getBuyInitializationStatus(): Flow { @@ -134,33 +127,76 @@ internal class DefaultRampManager( private suspend fun getExchangeableState( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - ): ExchangeableState { - return withContext(dispatchers.io) { - val asset = expressServiceLoader.getInitializationStatus(userWalletId).firstOrNull() - ?: return@withContext ExchangeableState.Loading - when (asset) { - is Lce.Error -> ExchangeableState.Error - is Lce.Loading -> ExchangeableState.Loading - is Lce.Content -> { - val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(it) } - foundAsset?.exchangeAvailable?.toExchangeableState() ?: ExchangeableState.AssetNotFound - } + ): ExpressAvailabilityState { + val asset = expressServiceLoader.getInitializationStatus(userWalletId).firstOrNull() + ?: return ExpressAvailabilityState.Loading + return when (asset) { + is Lce.Error -> ExpressAvailabilityState.Error + is Lce.Loading -> ExpressAvailabilityState.Loading + is Lce.Content -> { + val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(it) } + foundAsset?.exchangeAvailable?.toSwapAvailabilityState() + ?: ExpressAvailabilityState.AssetNotFound } } } - private fun Boolean.toExchangeableState(): ExchangeableState { - return if (this) { - ExchangeableState.Exchangeable - } else { - ExchangeableState.NotExchangeable + private suspend fun getOnrampAvailableState( + scanResponse: ScanResponse, + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): ExpressAvailabilityState { + return when { + onrampFeatureToggles.isFeatureEnabled -> { + val asset = expressServiceLoader.getInitializationStatus(userWalletId).firstOrNull() + ?: return ExpressAvailabilityState.Loading + return when (asset) { + is Lce.Error -> ExpressAvailabilityState.Error + is Lce.Loading -> ExpressAvailabilityState.Loading + is Lce.Content -> { + val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(it) } + foundAsset?.onrampAvailable?.toOnrampAvailabilityState() + ?: ExpressAvailabilityState.AssetNotFound + } + } + } + exchangeService != null -> { + exchangeService.availableForBuy( + scanResponse = scanResponse, + currency = cryptoCurrencyConverter.convertBack(cryptoCurrency), + ).toOnrampAvailabilityState() + } + else -> ExpressAvailabilityState.Error } } - private suspend fun getOnrampAvailable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { - val asset = expressAssetsStore.getSyncOrNull(userWalletId)?.find { cryptoCurrency.findAssetPredicate(it) } + private fun ExpressAvailabilityState.toReason(currencyName: String): ScenarioUnavailabilityReason { + return when (this) { + ExpressAvailabilityState.Available -> { + ScenarioUnavailabilityReason.None + } + ExpressAvailabilityState.AssetNotFound -> ScenarioUnavailabilityReason.AssetNotFound(currencyName) + ExpressAvailabilityState.Error -> ScenarioUnavailabilityReason.ExpressUnreachable(currencyName) + ExpressAvailabilityState.Loading -> ScenarioUnavailabilityReason.ExpressLoading(currencyName) + ExpressAvailabilityState.NotOnrampable -> ScenarioUnavailabilityReason.BuyUnavailable(currencyName) + ExpressAvailabilityState.NotExchangeable -> ScenarioUnavailabilityReason.NotExchangeable(currencyName) + } + } - return asset?.onrampAvailable ?: false + private fun Boolean.toSwapAvailabilityState(): ExpressAvailabilityState { + return if (this) { + ExpressAvailabilityState.Available + } else { + ExpressAvailabilityState.NotExchangeable + } + } + + private fun Boolean.toOnrampAvailabilityState(): ExpressAvailabilityState { + return if (this) { + ExpressAvailabilityState.Available + } else { + ExpressAvailabilityState.NotOnrampable + } } private fun CryptoCurrency.findAssetPredicate(asset: Asset): Boolean { diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/mercuryo/MercuryoService.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/mercuryo/MercuryoService.kt index a892310a6f..b398116c05 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/mercuryo/MercuryoService.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/mercuryo/MercuryoService.kt @@ -6,6 +6,7 @@ import com.tangem.common.extensions.calculateSha512 import com.tangem.common.extensions.toHexString import com.tangem.common.services.Result import com.tangem.common.services.performRequest +import com.tangem.data.onramp.legacy.mercuryoNetwork import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError import com.tangem.domain.core.utils.lceLoading diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt index a0f0c7f157..235cf6be4c 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonpayBlockchainMapping.kt @@ -157,4 +157,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency? Scroll, ScrollTestnet -> null ZkLinkNova, ZkLinkNovaTestnet -> null KaspaTestnet -> null + Pepecoin, PepecoinTestnet -> null } \ No newline at end of file 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 08b6c9ba8a..2525bdd3cd 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 @@ -26,13 +26,13 @@ import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.onramp.OnrampFeatureToggles +import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository import com.tangem.tap.domain.walletconnect2.domain.WalletConnectInteractor @@ -56,7 +56,6 @@ data class DaggerGraphState( val generalUserWalletsListManager: UserWalletsListManager? = null, val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase? = null, val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase? = null, - val generateWalletNameUseCase: GenerateWalletNameUseCase? = null, val cardRepository: CardRepository? = null, val settingsRepository: SettingsRepository? = null, val blockchainSDKFactory: BlockchainSDKFactory? = null, @@ -67,7 +66,6 @@ data class DaggerGraphState( val shareManager: ShareManager? = null, val appRouter: AppRouter? = null, val transactionSignerFactory: TransactionSignerFactory? = null, - val getUserCountryUseCase: GetUserCountryUseCase? = null, val onrampFeatureToggles: OnrampFeatureToggles? = null, val environmentConfigStorage: EnvironmentConfigStorage? = null, val onboardingV2FeatureToggles: OnboardingV2FeatureToggles? = null, @@ -77,4 +75,6 @@ data class DaggerGraphState( val clipboardManager: ClipboardManager? = null, val settingsManager: SettingsManager? = null, val uiMessageSender: UiMessageSender? = null, + val onlineCardVerifier: OnlineCardVerifier? = null, + val userWalletBuilderFactory: UserWalletBuilder.Factory? = null, ) : StateType \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt b/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt index d744032000..bcebe2fe83 100644 --- a/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt +++ b/app/src/main/java/com/tangem/tap/routing/ProxyAppRouter.kt @@ -1,8 +1,9 @@ package com.tangem.tap.routing -import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.decompose.navigation.Router import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -15,6 +16,7 @@ import kotlin.reflect.KClass internal class ProxyAppRouter( private val config: AppRouterConfig, private val dispatchers: CoroutineDispatcherProvider, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, ) : AppRouter { private val routerScope: CoroutineScope @@ -76,7 +78,7 @@ internal class ProxyAppRouter( override fun defaultCompletionHandler(isSuccess: Boolean, errorMessage: String) { if (!isSuccess) { - FirebaseCrashlytics.getInstance().recordException(RuntimeException(errorMessage)) + analyticsExceptionHandler.sendException(ExceptionAnalyticsEvent(RuntimeException(errorMessage))) Timber.w(errorMessage) with(receiver = config.snackbarHandler ?: return) { diff --git a/app/src/main/java/com/tangem/tap/routing/RootContent.kt b/app/src/main/java/com/tangem/tap/routing/RootContent.kt index 669e7f44a3..d0c69a7abe 100644 --- a/app/src/main/java/com/tangem/tap/routing/RootContent.kt +++ b/app/src/main/java/com/tangem/tap/routing/RootContent.kt @@ -2,28 +2,37 @@ package com.tangem.tap.routing import android.app.Activity import android.os.Bundle +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat.startActivity +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.* import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.value.Value import com.tangem.common.routing.AppRoute import com.tangem.core.ui.UiDependencies import com.tangem.core.ui.components.snackbar.TangemSnackbarHost import com.tangem.core.ui.message.EventMessageEffect +import com.tangem.core.ui.res.LocalIsNavigationRefactoringEnabled import com.tangem.core.ui.res.LocalSnackbarHostState import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.utils.WindowInsetsZero import com.tangem.tap.routing.component.RoutingComponent +@OptIn(ExperimentalDecomposeApi::class) @Composable internal fun RootContent( - stack: ChildStack, + stack: Value>, uiDependencies: UiDependencies, modifier: Modifier = Modifier, ) { @@ -33,21 +42,15 @@ internal fun RootContent( activity = context as Activity, uiDependencies = uiDependencies, ) { - val snackbarHostState = LocalSnackbarHostState.current + CompositionLocalProvider( + LocalIsNavigationRefactoringEnabled provides true, + ) { + val snackbarHostState = LocalSnackbarHostState.current - Scaffold( - modifier = modifier, - containerColor = TangemTheme.colors.background.primary, - snackbarHost = { - TangemSnackbarHost( - modifier = Modifier.padding(all = 16.dp), - hostState = snackbarHostState, - ) - }, - contentWindowInsets = WindowInsetsZero, - content = { paddingValues -> + Box(Modifier.background(TangemTheme.colors.background.primary)) { Children( - modifier = Modifier.padding(paddingValues), + modifier = modifier, + animation = stackAnimation(fade()), stack = stack, ) { child -> when (val instance = child.instance) { @@ -57,14 +60,24 @@ internal fun RootContent( } is RoutingComponent.Child.LegacyIntent -> { // TODO: Remove and use it's own router: [REDACTED_JIRA] - startActivity(context, instance.intent, Bundle.EMPTY) + LaunchedEffect(instance) { + startActivity(context, instance.intent, Bundle.EMPTY) + } } is RoutingComponent.Child.LegacyFragment, -> error("Unsupported child: $instance") } } - }, - ) + + TangemSnackbarHost( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(all = 16.dp), + hostState = snackbarHostState, + ) + } + } EventMessageEffect() } diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index cafffe14d3..759951e25e 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -4,12 +4,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.subscribe -import com.arkivanov.essenty.backhandler.BackCallback import com.arkivanov.essenty.lifecycle.doOnDestroy import com.google.android.material.snackbar.Snackbar import com.tangem.common.routing.AppRoute @@ -43,19 +41,15 @@ internal class DefaultRoutingComponent @AssistedInject constructor( AppComponentContext by context, SnackbarHandler { - private val backCallback = BackCallback(priority = Int.MIN_VALUE, onBack = router::pop) - override val stack: Value> = childStack( source = navigationProvider.getOrCreateTyped(), initialStack = { getInitialStackOrInit() }, serializer = null, // AppRoute.serializer(), // Disabled until Nav refactoring completes - handleBackButton = false, + handleBackButton = routingFeatureToggles.isNavigationRefactoringEnabled, childFactory = ::child, ) init { - backHandler.register(backCallback) - lifecycle.doOnDestroy { childFactory.doOnDestroy() } @@ -77,8 +71,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val stack by this.stack.subscribeAsState() - RootContent( modifier = modifier, stack = stack, 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 1005d79f38..3c0ac636bb 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 @@ -2,6 +2,7 @@ package com.tangem.tap.routing.utils import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.qrscanning.models.SourceType import com.tangem.feature.qrscanning.QrScanningComponent import com.tangem.feature.referral.api.ReferralComponent import com.tangem.feature.stories.api.StoriesComponent @@ -11,15 +12,20 @@ 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.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.swap.SwapComponent +import com.tangem.features.send.v2.api.SendFeatureToggles import com.tangem.features.staking.api.StakingComponent +import com.tangem.features.swap.SwapComponent import com.tangem.features.tester.api.TesterRouter import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.wallet.WalletEntryComponent +import com.tangem.tap.domain.walletconnect2.toggles.WalletConnectFeatureToggles import com.tangem.tap.features.details.ui.appcurrency.api.AppCurrencySelectorComponent import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent @@ -40,6 +46,7 @@ import com.tangem.utils.Provider import dagger.hilt.android.scopes.ActivityScoped import java.util.WeakHashMap import javax.inject.Inject +import com.tangem.features.walletconnect.components.WalletConnectEntryComponent as RedisegnedWalletConnectComponent @ActivityScoped @Suppress("LongParameterList", "LargeClass") @@ -73,37 +80,41 @@ internal class ChildFactory @Inject constructor( private val referralComponentFactory: ReferralComponent.Factory, private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, private val walletComponentFactory: WalletEntryComponent.Factory, + 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 testerRouter: TesterRouter, private val routingFeatureToggles: RoutingFeatureToggles, + private val walletConnectFeatureToggles: WalletConnectFeatureToggles, ) { fun createChild(route: AppRoute, contextFactory: (route: AppRoute) -> AppComponentContext): Child { return if (routingFeatureToggles.isNavigationRefactoringEnabled) { - createChildNew(route, contextFactory) + createChildNew(route, contextFactory(route)) } else { createChildLegacy(route, contextFactory) } } @Suppress("LongMethod", "CyclomaticComplexMethod") - private fun createChildNew(route: AppRoute, contextFactory: (route: AppRoute) -> AppComponentContext): Child { - componentContexts[route] = contextFactory(route) - - // region Child creation + private fun createChildNew(route: AppRoute, context: AppComponentContext): Child { return when (route) { is AppRoute.Initial -> { Child.Initial } is AppRoute.Details -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = DetailsComponent.Params(route.userWalletId), componentFactory = detailsComponentFactory, ) } is AppRoute.Disclaimer -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = DisclaimerComponent.Params(route.isTosAccepted), componentFactory = disclaimerComponentFactory, ) @@ -116,14 +127,14 @@ internal class ChildFactory @Inject constructor( } createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = ManageTokensComponent.Params(route.userWalletId, source), componentFactory = manageTokensComponentFactory, ) } is AppRoute.Welcome -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = WelcomeComponent.Params( intent = route.intent, ), @@ -135,14 +146,14 @@ internal class ChildFactory @Inject constructor( } is AppRoute.WalletSettings -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = WalletSettingsComponent.Params(route.userWalletId), componentFactory = walletSettingsComponentFactory, ) } is AppRoute.MarketsTokenDetails -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = MarketsTokenDetailsComponent.Params( token = route.token, appCurrency = route.appCurrency, @@ -159,7 +170,7 @@ internal class ChildFactory @Inject constructor( } is AppRoute.Onramp -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = OnrampComponent.Params( userWalletId = route.userWalletId, cryptoCurrency = route.currency, @@ -170,42 +181,45 @@ internal class ChildFactory @Inject constructor( } is AppRoute.OnrampSuccess -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = OnrampSuccessComponent.Params(route.externalTxId), componentFactory = onrampSuccessComponentFactory, ) } is AppRoute.BuyCrypto -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = BuyCryptoComponent.Params(userWalletId = route.userWalletId), componentFactory = buyCryptoComponentFactory, ) } is AppRoute.SellCrypto -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = SellCryptoComponent.Params(userWalletId = route.userWalletId), componentFactory = sellCryptoComponentFactory, ) } is AppRoute.SwapCrypto -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = SwapSelectTokensComponent.Params(userWalletId = route.userWalletId), componentFactory = swapSelectTokensComponentFactory, ) } is AppRoute.Onboarding -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = OnboardingEntryComponent.Params( scanResponse = route.scanResponse, - multiWalletMode = when (route.mode) { - AppRoute.Onboarding.Mode.Onboarding -> OnboardingEntryComponent.MultiWalletMode.Onboarding - AppRoute.Onboarding.Mode.AddBackup -> OnboardingEntryComponent.MultiWalletMode.AddBackup + mode = when (route.mode) { + AppRoute.Onboarding.Mode.Onboarding -> OnboardingEntryComponent.Mode.Onboarding + AppRoute.Onboarding.Mode.AddBackupWallet1 -> OnboardingEntryComponent.Mode.AddBackupWallet1 + AppRoute.Onboarding.Mode.WelcomeOnlyTwin -> OnboardingEntryComponent.Mode.WelcomeOnlyTwin + AppRoute.Onboarding.Mode.RecreateWalletTwin -> + OnboardingEntryComponent.Mode.RecreateWalletTwin AppRoute.Onboarding.Mode.ContinueFinalize -> - OnboardingEntryComponent.MultiWalletMode.ContinueFinalize + OnboardingEntryComponent.Mode.ContinueFinalize }, ), componentFactory = onboardingEntryComponentFactory, @@ -213,7 +227,7 @@ internal class ChildFactory @Inject constructor( } is AppRoute.Stories -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = StoriesComponent.Params( storyId = route.storyId, nextScreen = route.nextScreen, @@ -224,7 +238,7 @@ internal class ChildFactory @Inject constructor( } is AppRoute.CurrencyDetails -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = TokenDetailsComponent.Params( userWalletId = route.userWalletId, currency = route.currency, @@ -234,7 +248,7 @@ internal class ChildFactory @Inject constructor( } is AppRoute.Staking -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = StakingComponent.Params( userWalletId = route.userWalletId, cryptoCurrencyId = route.cryptoCurrencyId, @@ -245,7 +259,7 @@ internal class ChildFactory @Inject constructor( } is AppRoute.Swap -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = SwapComponent.Params( currencyFrom = route.currencyFrom, currencyTo = route.currencyTo, @@ -257,74 +271,101 @@ internal class ChildFactory @Inject constructor( ) } is AppRoute.Send -> { - createComponentChild( - contextProvider = contextProvider(route, contextFactory), - params = SendComponent.Params( - userWalletId = route.userWalletId, - currency = route.currency, - transactionId = route.transactionId, - amount = route.amount, - tag = route.tag, - destinationAddress = route.destinationAddress, - ), - componentFactory = sendComponentFactory, - ) + if (sendFeatureToggles.isSendV2Enabled) { + createComponentChild( + context = context, + params = com.tangem.features.send.v2.api.SendComponent.Params( + userWalletId = route.userWalletId, + currency = route.currency, + transactionId = route.transactionId, + amount = route.amount, + tag = route.tag, + destinationAddress = route.destinationAddress, + ), + componentFactory = sendComponentFactoryV2, + ) + } else { + createComponentChild( + context = context, + params = SendComponent.Params( + userWalletId = route.userWalletId, + currency = route.currency, + transactionId = route.transactionId, + amount = route.amount, + tag = route.tag, + destinationAddress = route.destinationAddress, + ), + componentFactory = sendComponentFactory, + ) + } } is AppRoute.Home -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = Unit, componentFactory = homeComponentFactory, ) } is AppRoute.WalletConnectSessions -> { - createComponentChild( - contextProvider = contextProvider(route, contextFactory), - params = Unit, - componentFactory = walletConnectComponentFactory, - ) + if (walletConnectFeatureToggles.isRedesignedWalletConnectEnabled) { + createComponentChild( + context = context, + params = Unit, + componentFactory = redesignedWalletConnectComponentFactory, + ) + } else { + createComponentChild( + context = context, + params = Unit, + componentFactory = walletConnectComponentFactory, + ) + } } is AppRoute.QrScanning -> { + val source = when (route.source) { + is AppRoute.QrScanning.Source.Send -> SourceType.SEND + is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT + } createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = QrScanningComponent.Params( - source = route.source, - networkName = route.networkName, + source = source, + networkName = (route.source as? AppRoute.QrScanning.Source.Send)?.networkName, ), componentFactory = qrScanningComponentFactory, ) } is AppRoute.AccessCodeRecovery -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = Unit, componentFactory = accessCodeRecoveryComponentFactory, ) } is AppRoute.CardSettings -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = CardSettingsComponent.Params(userWalletId = route.userWalletId), componentFactory = cardSettingsComponentFactory, ) } is AppRoute.AppCurrencySelector -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = Unit, componentFactory = appCurrencySelectorComponentFactory, ) } is AppRoute.AppSettings -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = Unit, componentFactory = appSettingsComponentFactory, ) } is AppRoute.DetailsSecurity -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = SecurityModeComponent.Params( userWalletId = route.userWalletId, ), @@ -333,7 +374,7 @@ internal class ChildFactory @Inject constructor( } is AppRoute.ResetToFactory -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = ResetCardComponent.Params( userWalletId = route.userWalletId, cardId = route.cardId, @@ -345,25 +386,43 @@ internal class ChildFactory @Inject constructor( } is AppRoute.ReferralProgram -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = ReferralComponent.Params(route.userWalletId), componentFactory = referralComponentFactory, ) } is AppRoute.PushNotification -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = Unit, componentFactory = pushNotificationsComponentFactory, ) } is AppRoute.Wallet -> { createComponentChild( - contextProvider = contextProvider(route, contextFactory), + context = context, params = Unit, componentFactory = walletComponentFactory, ) } + is AppRoute.NFTCollections -> + createComponentChild( + context = context, + params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId), + componentFactory = nftCollectionsComponentFactory, + ) + is AppRoute.NFTReceive -> + 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, + ) is AppRoute.OnboardingNote, is AppRoute.SaveWallet, is AppRoute.OnboardingOther, @@ -401,18 +460,33 @@ internal class ChildFactory @Inject constructor( route.asFragmentChild(Provider { SaveWalletBottomSheetFragment() }) } is AppRoute.Send -> { - route.asComponentChild( - contextProvider = contextProvider(route, contextFactory), - params = SendComponent.Params( - userWalletId = route.userWalletId, - currency = route.currency, - transactionId = route.transactionId, - amount = route.amount, - tag = route.tag, - destinationAddress = route.destinationAddress, - ), - componentFactory = sendComponentFactory, - ) + if (sendFeatureToggles.isSendV2Enabled) { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = com.tangem.features.send.v2.api.SendComponent.Params( + userWalletId = route.userWalletId, + currency = route.currency, + transactionId = route.transactionId, + amount = route.amount, + tag = route.tag, + destinationAddress = route.destinationAddress, + ), + componentFactory = sendComponentFactoryV2, + ) + } else { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = SendComponent.Params( + userWalletId = route.userWalletId, + currency = route.currency, + transactionId = route.transactionId, + amount = route.amount, + tag = route.tag, + destinationAddress = route.destinationAddress, + ), + componentFactory = sendComponentFactory, + ) + } } is AppRoute.AppSettings -> { route.asComponentChild( @@ -484,11 +558,15 @@ internal class ChildFactory @Inject constructor( route.asFragmentChild(Provider { OnboardingWalletFragment() }) } is AppRoute.QrScanning -> { + val source = when (route.source) { + is AppRoute.QrScanning.Source.Send -> SourceType.SEND + is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT + } route.asComponentChild( contextProvider = contextProvider(route, contextFactory), params = QrScanningComponent.Params( - source = route.source, - networkName = route.networkName, + source = source, + networkName = (route.source as? AppRoute.QrScanning.Source.Send)?.networkName, ), componentFactory = qrScanningComponentFactory, ) @@ -533,11 +611,19 @@ internal class ChildFactory @Inject constructor( ) } is AppRoute.WalletConnectSessions -> { - route.asComponentChild( - contextProvider = contextProvider(route, contextFactory), - params = Unit, - componentFactory = walletConnectComponentFactory, - ) + if (walletConnectFeatureToggles.isRedesignedWalletConnectEnabled) { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = Unit, + componentFactory = redesignedWalletConnectComponentFactory, + ) + } else { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = Unit, + componentFactory = walletConnectComponentFactory, + ) + } } is AppRoute.CurrencyDetails -> { route.asComponentChild( @@ -647,11 +733,14 @@ internal class ChildFactory @Inject constructor( contextProvider = contextProvider(route, contextFactory), params = OnboardingEntryComponent.Params( scanResponse = route.scanResponse, - multiWalletMode = when (route.mode) { - AppRoute.Onboarding.Mode.Onboarding -> OnboardingEntryComponent.MultiWalletMode.Onboarding - AppRoute.Onboarding.Mode.AddBackup -> OnboardingEntryComponent.MultiWalletMode.AddBackup + mode = when (route.mode) { + AppRoute.Onboarding.Mode.Onboarding -> OnboardingEntryComponent.Mode.Onboarding + AppRoute.Onboarding.Mode.AddBackupWallet1 -> OnboardingEntryComponent.Mode.AddBackupWallet1 + AppRoute.Onboarding.Mode.WelcomeOnlyTwin -> OnboardingEntryComponent.Mode.WelcomeOnlyTwin + AppRoute.Onboarding.Mode.RecreateWalletTwin -> + OnboardingEntryComponent.Mode.RecreateWalletTwin AppRoute.Onboarding.Mode.ContinueFinalize -> - OnboardingEntryComponent.MultiWalletMode.ContinueFinalize + OnboardingEntryComponent.Mode.ContinueFinalize }, ), componentFactory = onboardingEntryComponentFactory, @@ -668,6 +757,24 @@ internal class ChildFactory @Inject constructor( componentFactory = storiesComponentFactory, ) } + is AppRoute.NFTCollections -> + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = NFTCollectionsComponent.Params(userWalletId = route.userWalletId), + componentFactory = nftCollectionsComponentFactory, + ) + is AppRoute.NFTReceive -> + 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, + ) } // endregion } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/RouteMappers.kt b/app/src/main/java/com/tangem/tap/routing/utils/RouteMappers.kt index e7aa49f4d9..f20d2b096b 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/RouteMappers.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/RouteMappers.kt @@ -40,11 +40,9 @@ internal fun > createComponentChild( - contextProvider: Provider, + context: AppComponentContext, params: P, componentFactory: F, ): Child { - val context = contextProvider() - return Child.ComposableComponent(componentFactory.create(context, params)) } \ No newline at end of file diff --git a/app/src/main/res/layout/layout_backup_access_code_info.xml b/app/src/main/res/layout/layout_backup_access_code_info.xml index c53bb8fc3d..5095c1eca0 100644 --- a/app/src/main/res/layout/layout_backup_access_code_info.xml +++ b/app/src/main/res/layout/layout_backup_access_code_info.xml @@ -9,7 +9,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="@dimen/access_code_info_protect_margin_top" - android:src="@drawable/ic_access_code" + android:src="@drawable/ic_access_code_119" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" 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 095156e1c2..c66581f06d 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 @@ -4,6 +4,8 @@ import android.annotation.SuppressLint import com.google.common.truth.Truth import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.CompletionResult +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.ScanCardException import com.tangem.domain.common.configs.GenericCardConfig @@ -40,7 +42,7 @@ internal class DefaultDerivationsRepositoryTest { artworkUrl = "", cardsInWallet = setOf(), isMultiCurrency = false, - scanResponse = ScanResponseMockFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()), + scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()), hasBackupError = false, ) @@ -77,7 +79,7 @@ internal class DefaultDerivationsRepositoryTest { @Test fun `success if currencies is empty`() = runTest { val userWallet = defaultUserWallet.copy( - scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), + scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), ) coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns userWallet @@ -94,7 +96,7 @@ internal class DefaultDerivationsRepositoryTest { @Test fun `success if card already has derivations`() = runTest { val userWallet = defaultUserWallet.copy( - scanResponse = ScanResponseMockFactory.create( + scanResponse = MockScanResponseFactory.create( cardConfig = MultiWalletCardConfig, derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, ), @@ -105,7 +107,7 @@ internal class DefaultDerivationsRepositoryTest { runCatching { repository.derivePublicKeys( userWalletId = defaultUserWalletId, - currencies = CryptoCurrenciesMocks(userWallet.scanResponse).ethereum, + currencies = MockCryptoCurrencyFactory(userWallet.scanResponse).ethereum.let(::listOf), ) } .onSuccess { Truth.assertThat(it) } @@ -119,7 +121,7 @@ internal class DefaultDerivationsRepositoryTest { @Test fun `error if tangemSdkManager throws exception`() = runTest { val userWallet = defaultUserWallet.copy( - scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), + scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), ) coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns userWallet coEvery { tangemSdkManager.derivePublicKeys(null, any(), any()) } throws ScanCardException.UserCancelled @@ -127,7 +129,7 @@ internal class DefaultDerivationsRepositoryTest { runCatching { repository.derivePublicKeys( userWalletId = defaultUserWalletId, - currencies = CryptoCurrenciesMocks(userWallet.scanResponse).ethereum, + currencies = MockCryptoCurrencyFactory(userWallet.scanResponse).ethereum.let(::listOf), ) } .onSuccess { error("Should throws exception") } @@ -142,7 +144,7 @@ internal class DefaultDerivationsRepositoryTest { @Test fun `success case`() = runTest { val userWallet = defaultUserWallet.copy( - scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), + scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()), ) coEvery { userWalletsStore.getSyncOrNull(defaultUserWalletId) } returns userWallet coEvery { tangemSdkManager.derivePublicKeys(null, any(), any()) } returns CompletionResult.Success( @@ -153,7 +155,7 @@ internal class DefaultDerivationsRepositoryTest { runCatching { repository.derivePublicKeys( userWalletId = defaultUserWalletId, - currencies = CryptoCurrenciesMocks(userWallet.scanResponse).ethereum, + currencies = MockCryptoCurrencyFactory(userWallet.scanResponse).ethereum.let(::listOf), ) } .onSuccess { Truth.assertThat(it) } diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/MissedDerivationsFinderTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/card/MissedDerivationsFinderTest.kt index 7091e26f4f..063a5bc85b 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/card/MissedDerivationsFinderTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/MissedDerivationsFinderTest.kt @@ -6,6 +6,8 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.derivation.DerivationConfigV2 import com.tangem.common.card.EllipticCurve import com.tangem.common.extensions.ByteArrayKey +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.domain.common.configs.GenericCardConfig import com.tangem.domain.common.configs.MultiWalletCardConfig @@ -20,7 +22,7 @@ internal class MissedDerivationsFinderTest { @Test fun `empty derivations for empty currencies`() { - val scanResponse = ScanResponseMockFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()) + val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()) val finder = MissedDerivationsFinder(scanResponse) val actual = finder.find(emptyList()) @@ -31,10 +33,10 @@ internal class MissedDerivationsFinderTest { @Test fun `empty derivations for non supported blockchains`() { // Bls is not supported - val scanResponse = ScanResponseMockFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()) + val scanResponse = MockScanResponseFactory.create(cardConfig = GenericCardConfig(2), derivedKeys = emptyMap()) val finder = MissedDerivationsFinder(scanResponse) - val currencies = CryptoCurrenciesMocks(scanResponse).chia + val currencies = MockCryptoCurrencyFactory(scanResponse).chia.let(::listOf) val actual = finder.find(currencies) Truth.assertThat(actual).isEmpty() @@ -43,7 +45,7 @@ internal class MissedDerivationsFinderTest { @Test fun `derivations ONLY for supported blockchains`() { // Bls is not supported - val scanResponse = ScanResponseMockFactory.create( + val scanResponse = MockScanResponseFactory.create( cardConfig = GenericCardConfig(2), derivedKeys = emptyMap(), ).let { @@ -55,7 +57,7 @@ internal class MissedDerivationsFinderTest { } val finder = MissedDerivationsFinder(scanResponse) - val currencies = CryptoCurrenciesMocks(scanResponse).chiaAndEthereum + val currencies = MockCryptoCurrencyFactory(scanResponse).chiaAndEthereum val actual = finder.find(currencies) Truth.assertThat(actual).containsExactly( @@ -66,10 +68,10 @@ internal class MissedDerivationsFinderTest { @Test fun `derivations for custom token`() { - val scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) + val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) val finder = MissedDerivationsFinder(scanResponse) - val currencies = CryptoCurrenciesMocks(scanResponse).ethereumTokenWithBinanceDerivation + val currencies = MockCryptoCurrencyFactory(scanResponse).ethereumTokenWithBinanceDerivation val actual = finder.find(currencies) Truth.assertThat(actual).containsExactly( @@ -83,10 +85,10 @@ internal class MissedDerivationsFinderTest { @Test fun `derivations for cardano`() { - val scanResponse = ScanResponseMockFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) + val scanResponse = MockScanResponseFactory.create(cardConfig = MultiWalletCardConfig, derivedKeys = emptyMap()) val finder = MissedDerivationsFinder(scanResponse) - val currencies = CryptoCurrenciesMocks(scanResponse).cardano + val currencies = MockCryptoCurrencyFactory(scanResponse).cardano.let(::listOf) val actual = finder.find(currencies) Truth.assertThat(actual).containsExactly( @@ -105,13 +107,13 @@ internal class MissedDerivationsFinderTest { @Test fun `empty derivations for already derived currencies`() { - val scanResponse = ScanResponseMockFactory.create( + val scanResponse = MockScanResponseFactory.create( cardConfig = Wallet2CardConfig, derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, ) val finder = MissedDerivationsFinder(scanResponse) - val currencies = CryptoCurrenciesMocks(scanResponse).ethereum + val currencies = MockCryptoCurrencyFactory(scanResponse).ethereum.let(::listOf) val actual = finder.find(currencies) Truth.assertThat(actual).isEmpty() @@ -119,13 +121,13 @@ internal class MissedDerivationsFinderTest { @Test fun `derivations ONLY for never derived currencies`() { - val scanResponse = ScanResponseMockFactory.create( + val scanResponse = MockScanResponseFactory.create( cardConfig = MultiWalletCardConfig, derivedKeys = DerivedKeysMocks.ethereumDerivedKeys, ) val finder = MissedDerivationsFinder(scanResponse) - val currencies = CryptoCurrenciesMocks(scanResponse).ethereumAndStellar + val currencies = MockCryptoCurrencyFactory(scanResponse).ethereumAndStellar val actual = finder.find(currencies) Truth.assertThat(actual).containsExactly( 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 6d22031f4c..8180ca85ab 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 @@ -1,10 +1,10 @@ package com.tangem.tap.domain.userWalletList.implementation import com.google.common.truth.Truth +import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.domain.common.configs.GenericCardConfig import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.tap.domain.card.ScanResponseMockFactory import io.mockk.mockk import org.junit.Test import org.junit.runner.RunWith @@ -198,7 +198,7 @@ internal class BiometricUserWalletsListManagerTest(private val model: Model) { cardsInWallet = emptySet(), isMultiCurrency = true, hasBackupError = false, - scanResponse = ScanResponseMockFactory.create( + scanResponse = MockScanResponseFactory.create( cardConfig = GenericCardConfig(maxWalletCount = 1), derivedKeys = emptyMap(), ).let { diff --git a/build.gradle.kts b/build.gradle.kts index 4cbeef6686..1b240b2cfd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,41 +59,9 @@ 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 external APK and generates compose metrics to 'build/compose-metrics' directory" + description = "Build internal APK and generates compose metrics to 'build/compose-metrics' directory" subprojects { tasks.withType { @@ -118,5 +86,5 @@ val generateComposeMetrics by tasks.registering { } } - finalizedBy(assembleExternalQA) + finalizedBy(assembleInternalQA) } \ No newline at end of file diff --git a/common/routing/build.gradle.kts b/common/routing/build.gradle.kts index 4fd88cfb46..285041a42a 100644 --- a/common/routing/build.gradle.kts +++ b/common/routing/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(projects.domain.markets.models) implementation(projects.domain.onramp.models) implementation(projects.domain.appCurrency.models) + implementation(projects.domain.nft.models) /* Libs - Other */ api(deps.kotlin.serialization) 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 eb0c41ce29..cc4bda0a08 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 @@ -8,8 +8,8 @@ import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.onramp.model.OnrampSource -import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId import kotlinx.serialization.Serializable @@ -146,10 +146,21 @@ sealed class AppRoute(val path: String) : Route { data object WalletConnectSessions : AppRoute(path = "/wallet_connect_sessions") @Serializable - data class QrScanning( - val source: SourceType, - val networkName: String? = null, - ) : AppRoute(path = "/$source/qr_scanning${if (networkName != null) "/$networkName" else ""}") + data class QrScanning(val source: Source) : AppRoute(path = "/$source/qr_scanning${source.path}") { + + @Serializable + sealed class Source { + val path: String + get() = when (this) { + is Send -> "/$networkName" + WalletConnect -> "" + } + + data class Send(val networkName: String) : Source() + + data object WalletConnect : Source() + } + } @Serializable data class ReferralProgram( @@ -255,7 +266,9 @@ sealed class AppRoute(val path: String) : Route { enum class Mode { Onboarding, // general Mode - AddBackup, // continue backup process for existing wallet 1 + AddBackupWallet1, // continue backup process for existing wallet 1 + WelcomeOnlyTwin, // show welcome screen and then navigate to wallet for twins + RecreateWalletTwin, // reset twins ContinueFinalize, // continue finalize process (unfinished backup dialog) } } @@ -266,4 +279,20 @@ sealed class AppRoute(val path: String) : Route { val nextScreen: AppRoute, val screenSource: String, ) : AppRoute(path = "/stories$storyId") + + @Serializable + data class NFTCollections( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/nft_collections/${userWalletId.stringValue}") + + @Serializable + data class NFTReceive( + val userWalletId: UserWalletId, + ) : AppRoute(path = "/nft_receive/${userWalletId.stringValue}") + + @Serializable + data class NFTDetails( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + ) : AppRoute(path = "/nft_details/${userWalletId.stringValue}/${nftAsset.collectionId}/${nftAsset.id.stringValue}") } \ No newline at end of file diff --git a/common/test/.gitignore b/common/test/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/common/test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/common/test/build.gradle.kts b/common/test/build.gradle.kts new file mode 100644 index 0000000000..00c1510373 --- /dev/null +++ b/common/test/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.common.test" +} + +dependencies { + implementation(projects.data.common) + + implementation(projects.domain.legacy) + implementation(projects.domain.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) + + implementation(projects.libs.blockchainSdk) + + implementation(deps.androidx.datastore) + implementation(deps.test.coroutine) + + implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/datastore/MockStateDataStore.kt b/common/test/src/main/java/com/tangem/common/test/datastore/MockStateDataStore.kt new file mode 100644 index 0000000000..2fc75fe7f1 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/datastore/MockStateDataStore.kt @@ -0,0 +1,16 @@ +package com.tangem.common.test.datastore + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.updateAndGet + +class MockStateDataStore(default: T) : DataStore { + + private val _data = MutableStateFlow(value = default) + override val data: Flow = _data + + override suspend fun updateData(transform: suspend (t: T) -> T): T { + return _data.updateAndGet { transform(it) } + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/ScanResponseMockFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/card/MockScanResponseFactory.kt similarity index 98% rename from app/src/test/kotlin/com/tangem/tap/domain/card/ScanResponseMockFactory.kt rename to common/test/src/main/java/com/tangem/common/test/domain/card/MockScanResponseFactory.kt index da79e800f0..54108f07a9 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/card/ScanResponseMockFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/card/MockScanResponseFactory.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.domain.card +package com.tangem.common.test.domain.card import com.tangem.common.card.CardWallet import com.tangem.common.card.FirmwareVersion @@ -14,7 +14,7 @@ import java.util.Date /** [REDACTED_AUTHOR] */ -object ScanResponseMockFactory { +object MockScanResponseFactory { private val genericFirmwareVersion = CardDTO.FirmwareVersion( major = 3, diff --git a/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt new file mode 100644 index 0000000000..34153765ce --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/domain/network/MockNetworkStatusFactory.kt @@ -0,0 +1,58 @@ +package com.tangem.common.test.domain.network + +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkAddress +import com.tangem.domain.tokens.model.NetworkStatus +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +object MockNetworkStatusFactory { + + private val defaultNetwork = MockCryptoCurrencyFactory().ethereum.network + + fun createVerified(network: Network = defaultNetwork): NetworkStatus { + return NetworkStatus( + network = network, + value = NetworkStatus.Verified( + address = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x123", + type = NetworkAddress.Address.Type.Primary, + ), + ), + amounts = mapOf(), + pendingTransactions = mapOf(), + source = StatusSource.ACTUAL, + ), + ) + } + + fun createNoAccount(network: Network = defaultNetwork): NetworkStatus { + return NetworkStatus( + network = network, + value = NetworkStatus.NoAccount( + address = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x123", + type = NetworkAddress.Address.Type.Primary, + ), + ), + amountToCreateAccount = BigDecimal.ONE, + errorMessage = "", + source = StatusSource.ACTUAL, + ), + ) + } + + fun createMissedDerivation(network: Network = defaultNetwork): NetworkStatus { + return NetworkStatus(network = network, value = NetworkStatus.MissedDerivation) + } + + fun createUnreachable(network: Network = defaultNetwork): NetworkStatus { + return NetworkStatus(network = network, value = NetworkStatus.Unreachable(address = null)) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt similarity index 78% rename from app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt rename to common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt index dceee7e356..9af7183d2a 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/card/CryptoCurrenciesMocks.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt @@ -1,13 +1,15 @@ -package com.tangem.tap.domain.card +package com.tangem.common.test.domain.token import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.FeePaidCurrency import com.tangem.blockchain.common.Token import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.currency.getNetworkDerivationPath import com.tangem.data.common.currency.getNetworkStandardType +import com.tangem.domain.common.configs.GenericCardConfig import com.tangem.domain.common.util.derivationStyleProvider import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.model.CryptoCurrency @@ -16,13 +18,13 @@ import com.tangem.domain.tokens.model.Network /** [REDACTED_AUTHOR] */ -internal class CryptoCurrenciesMocks(private val scanResponse: ScanResponse) { +class MockCryptoCurrencyFactory(private val scanResponse: ScanResponse = defaultScanResponse) { private val factory = CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()) - val cardano by lazy { listOf(createCoin(blockchain = Blockchain.Cardano)) } - val chia by lazy { listOf(element = createCoin(Blockchain.Chia)) } - val ethereum by lazy { listOf(element = createCoin(Blockchain.Ethereum)) } + val cardano by lazy { createCoin(blockchain = Blockchain.Cardano) } + val chia by lazy { createCoin(Blockchain.Chia) } + val ethereum by lazy { createCoin(Blockchain.Ethereum) } val chiaAndEthereum by lazy { listOf( @@ -44,16 +46,16 @@ internal class CryptoCurrenciesMocks(private val scanResponse: ScanResponse) { ) } - private fun createCoin(blockchain: Blockchain): CryptoCurrency { + fun createCoin(blockchain: Blockchain): CryptoCurrency { val network = Network( id = Network.ID(blockchain.id), backendId = blockchain.toNetworkId(), name = blockchain.fullName, isTestnet = blockchain.isTestnet(), derivationPath = getNetworkDerivationPath( - blockchain, + blockchain = blockchain, extraDerivationPath = null, - scanResponse.derivationStyleProvider, + cardDerivationStyleProvider = scanResponse.derivationStyleProvider, ), currencySymbol = blockchain.currency, standardType = getNetworkStandardType(blockchain), @@ -66,7 +68,7 @@ internal class CryptoCurrenciesMocks(private val scanResponse: ScanResponse) { } // Impossible to create custom token by CryptoCurrencyFactory because it works with URI under the hood - private fun createCustomToken(blockchain: Blockchain, derivationBlockchain: Blockchain): CryptoCurrency { + fun createCustomToken(blockchain: Blockchain, derivationBlockchain: Blockchain): CryptoCurrency { return CryptoCurrency.Token( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, @@ -98,7 +100,7 @@ internal class CryptoCurrenciesMocks(private val scanResponse: ScanResponse) { ) } - private fun createToken(blockchain: Blockchain): CryptoCurrency { + fun createToken(blockchain: Blockchain): CryptoCurrency { return factory.createToken( sdkToken = Token(symbol = "NEVER-MIND", contractAddress = "NEVER-MIND", decimals = 8), blockchain = blockchain, @@ -106,4 +108,12 @@ internal class CryptoCurrenciesMocks(private val scanResponse: ScanResponse) { scanResponse = scanResponse, )!! } + + private companion object { + + val defaultScanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(2), + derivedKeys = emptyMap(), + ) + } } \ 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 new file mode 100644 index 0000000000..365267e1ba --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt @@ -0,0 +1,33 @@ +package com.tangem.common.test.domain.wallet + +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.domain.common.configs.GenericCardConfig +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.wallets.builder.UserWalletIdBuilder +import com.tangem.domain.wallets.models.UserWallet + +/** +[REDACTED_AUTHOR] + */ +object MockUserWalletFactory { + + private val defaultScanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(2), + derivedKeys = emptyMap(), + ) + + fun create(scanResponse: ScanResponse = defaultScanResponse): UserWallet { + val userWalletId = UserWalletIdBuilder.scanResponse(scanResponse).build()!! + + return UserWallet( + walletId = userWalletId, + name = "Wallet 1", + artworkUrl = "", + cardsInWallet = emptySet(), + scanResponse = scanResponse, + isMultiCurrency = scanResponse.cardTypesResolver.isMultiwalletAllowed(), + hasBackupError = false, + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..537e01b8f3 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/utils/CoroutineExt.kt @@ -0,0 +1,20 @@ +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.UnconfinedTestDispatcher + +@OptIn(ExperimentalCoroutinesApi::class) +fun CoroutineScope.getEmittedValues(testScheduler: TestCoroutineScheduler, actual: Flow): List { + val values = mutableListOf() + + launch(UnconfinedTestDispatcher(testScheduler)) { + actual.toList(values) + } + + return values +} \ No newline at end of file diff --git a/common/ui/build.gradle.kts b/common/ui/build.gradle.kts index 42a053ad9b..4cbc5cccfb 100644 --- a/common/ui/build.gradle.kts +++ b/common/ui/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { /** Project - Common */ implementation(projects.core.ui) implementation(projects.core.utils) + implementation(projects.core.res) implementation(projects.libs.crypto) /** Project - Domain */ @@ -34,6 +35,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.legacy) implementation(projects.domain.staking.models) + implementation(projects.domain.staking) implementation(projects.domain.tokens.models) implementation(projects.domain.transaction.models) implementation(projects.domain.wallets.models) diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt index cfd16d8ee2..d8a18ff233 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/AmountScreenContent.kt @@ -40,7 +40,12 @@ fun AmountScreenContent( bottom = TangemTheme.dimens.spacing16, ), ) { - amountField(amountState = amountState, isBalanceHidden = isBalanceHidden) + amountField( + amountState = amountState, + isBalanceHidden = isBalanceHidden, + onValueChange = clickIntents::onAmountValueChange, + onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, + ) buttons( segmentedButtonConfig = amountState.segmentedButtonConfig, clickIntents = clickIntents, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountIgnoreReduceTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountIgnoreReduceTransformer.kt new file mode 100644 index 0000000000..2255b4af03 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountIgnoreReduceTransformer.kt @@ -0,0 +1,11 @@ +package com.tangem.common.ui.amountScreen.converters + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.utils.transformer.Transformer + +object AmountIgnoreReduceTransformer : Transformer { + override fun transform(prevState: AmountState): AmountState { + val state = prevState as? AmountState.Data ?: return prevState + return state.copy(isIgnoreReduce = true) + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountPastedTriggerDismissTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountPastedTriggerDismissTransformer.kt index ebf69ed80d..ad6a224264 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountPastedTriggerDismissTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountPastedTriggerDismissTransformer.kt @@ -6,7 +6,7 @@ import com.tangem.utils.transformer.Transformer /** * Dismisses indication on pasted value */ -class AmountPastedTriggerDismissTransformer : Transformer { +object AmountPastedTriggerDismissTransformer : Transformer { override fun transform(prevState: AmountState): AmountState { if (prevState !is AmountState.Data) return prevState diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt index 4fbf0d991a..e52898094a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountReduceByTransformer.kt @@ -62,6 +62,7 @@ class AmountReduceByTransformer( val isCheckFailed = isExceedBalance || isLessThanMinimumIfProvided return prevState.copy( isPrimaryButtonEnabled = !isZero && !isCheckFailed, + reduceAmountBy = value.reduceAmountBy, amountTextField = amountTextField.copy( value = cryptoValue, fiatValue = fiatValue, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index f91822b7b7..94981c571b 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -3,6 +3,7 @@ package com.tangem.common.ui.amountScreen.converters import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter +import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverterV2 import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountSegmentedButtonsConfig import com.tangem.common.ui.amountScreen.models.AmountState @@ -30,6 +31,7 @@ import kotlinx.collections.immutable.persistentListOf * @property cryptoCurrencyStatusProvider current cryptocurrency status provider * @property iconStateConverter currency icon converter */ +@Deprecated("Use AmountStateConverterV2") class AmountStateConverter( private val clickIntents: AmountScreenClickIntents, private val appCurrencyProvider: Provider, @@ -80,4 +82,63 @@ class AmountStateConverter( selectedButton = 0, ) } +} + +/** + * Converts initial [String] to [AmountState] + * + * @property clickIntents amount screen clicks + * @property appCurrency selected app currency + * @property maxEnterAmount max enter amount data + * @property cryptoCurrencyStatus current cryptocurrency status + * @property iconStateConverter currency icon converter + */ +class AmountStateConverterV2( + private val clickIntents: AmountScreenClickIntents, + private val appCurrency: AppCurrency, + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val maxEnterAmount: EnterAmountBoundary, + private val iconStateConverter: CryptoCurrencyToIconStateConverter, +) : Converter { + + private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) { + AmountFieldConverterV2( + clickIntents = clickIntents, + cryptoCurrencyStatus = cryptoCurrencyStatus, + appCurrency = appCurrency, + ) + } + + override fun convert(value: AmountParameters): AmountState { + val fiat = maxEnterAmount.fiatAmount.format { fiat(appCurrency.code, appCurrency.symbol) } + val crypto = maxEnterAmount.amount.format { crypto(cryptoCurrencyStatus.currency) } + val noFeeRate = cryptoCurrencyStatus.value.fiatRate.isNullOrZero() + + return AmountState.Data( + title = value.title, + availableBalance = resourceReference(R.string.common_crypto_fiat_format, wrappedList(crypto, fiat)), + tokenIconState = iconStateConverter.convert(cryptoCurrencyStatus), + amountTextField = amountFieldConverter.convert(value.value), + isPrimaryButtonEnabled = false, + appCurrencyCode = appCurrency.code, + segmentedButtonConfig = persistentListOf( + AmountSegmentedButtonsConfig( + title = stringReference(cryptoCurrencyStatus.currency.symbol), + iconState = iconStateConverter.convertCustom( + value = cryptoCurrencyStatus, + forceGrayscale = noFeeRate, + showCustomTokenBadge = false, + ), + isFiat = false, + ), + AmountSegmentedButtonsConfig( + title = stringReference(appCurrency.code), + iconUrl = appCurrency.iconSmallUrl, + isFiat = true, + ), + ), + isSegmentedButtonsEnabled = !noFeeRate, + selectedButton = 0, + ) + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt index 392fa817ba..cbfc6907e8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldChangeTransformer.kt @@ -58,7 +58,7 @@ class AmountFieldChangeTransformer( val checkValue = if (amountTextField.isFiatValue) fiatValue else cryptoValue val isExceedBalance = checkValue.checkExceedBalance(maxEnterAmount, amountTextField) - val isLessThanMinimumIfProvided = minimumTransactionAmount?.amount?.let { decimalCryptoValue < it } ?: false + val isLessThanMinimumIfProvided = minimumTransactionAmount?.amount?.let { decimalCryptoValue < it } == true val isZero = if (amountTextField.isFiatValue) { decimalFiatValue.isNullOrZero() } else { @@ -67,6 +67,7 @@ class AmountFieldChangeTransformer( val isCheckFailed = isExceedBalance || isLessThanMinimumIfProvided return prevState.copy( isPrimaryButtonEnabled = !isZero && !isCheckFailed, + reduceAmountBy = BigDecimal.ZERO, amountTextField = amountTextField.copy( value = cryptoValue, fiatValue = fiatValue, @@ -74,10 +75,10 @@ class AmountFieldChangeTransformer( error = when { isExceedBalance -> resourceReference(R.string.send_validation_amount_exceeds_balance) isLessThanMinimumIfProvided -> { - val minimumAmount = minimumTransactionAmount - ?.amount - ?.format { crypto(cryptoCurrencyStatus.currency) } - .orEmpty() + val minimumAmount = minimumTransactionAmount?.amount.format { + crypto(cryptoCurrencyStatus.currency) + } + resourceReference( R.string.transfer_notification_invalid_minimum_transaction_amount_text, wrappedList(minimumAmount, minimumAmount), @@ -98,6 +99,7 @@ class AmountFieldChangeTransformer( private fun AmountState.Data.emptyState(): AmountState.Data { return copy( isPrimaryButtonEnabled = false, + reduceAmountBy = BigDecimal.ZERO, amountTextField = amountTextField.copy( value = "", fiatValue = "", diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt index d460c0ca5d..ce11f41fce 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldConverter.kt @@ -19,12 +19,13 @@ import com.tangem.utils.isNullOrZero import java.math.BigDecimal /** - * Converts initial [String] to [AmountField] + * Converts initial [String] to [AmountFieldModel] * * @property clickIntents amount screen clicks * @property appCurrencyProvider selected app currency provider * @property cryptoCurrencyStatusProvider current cryptocurrency status provider */ +@Deprecated("Use AmountFieldConverterV2") class AmountFieldConverter( private val clickIntents: AmountScreenClickIntents, private val cryptoCurrencyStatusProvider: Provider, @@ -76,6 +77,68 @@ class AmountFieldConverter( type = AmountType.FiatType(appCurrency.code), ) + private companion object { + private const val FIAT_DECIMALS = 2 + } +} + +/** + * Converts initial [String] to [AmountFieldModel] + * + * @property clickIntents amount screen clicks + * @property appCurrency selected app currency + * @property cryptoCurrencyStatus current cryptocurrency status + */ +class AmountFieldConverterV2( + private val clickIntents: AmountScreenClickIntents, + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val appCurrency: AppCurrency, +) : Converter { + + override fun convert(value: String): AmountFieldModel { + val cryptoDecimal = value.toBigDecimalOrNull() ?: BigDecimal.ZERO + val cryptoAmount = cryptoDecimal.convertToAmount(cryptoCurrencyStatus.currency) + val fiatRate = cryptoCurrencyStatus.value.fiatRate + val (fiatValue, fiatDecimal) = when { + fiatRate.isNullOrZero() -> "" to null + value.isEmpty() -> "" to BigDecimal.ZERO + else -> { + val fiatDecimal = fiatRate?.multiply(cryptoDecimal) + val fiatValue = fiatDecimal?.parseBigDecimal(FIAT_DECIMALS).orEmpty() + fiatValue to fiatDecimal + } + } + val isDoneActionEnabled = !cryptoDecimal.isNullOrZero() + return AmountFieldModel( + value = value, + fiatValue = fiatValue, + onValueChange = clickIntents::onAmountValueChange, + keyboardOptions = KeyboardOptions( + imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None, + keyboardType = KeyboardType.Number, + ), + keyboardActions = KeyboardActions( + onDone = { clickIntents.onAmountNext() }, + ), + isFiatValue = false, + cryptoAmount = cryptoAmount, + fiatAmount = getAppCurrencyAmount(fiatDecimal, appCurrency), + isError = false, + isWarning = false, + error = TextReference.EMPTY, + isFiatUnavailable = fiatRate == null, + isValuePasted = false, + onValuePastedTriggerDismiss = clickIntents::onAmountPasteTriggerDismiss, + ) + } + + private fun getAppCurrencyAmount(fiatValue: BigDecimal?, appCurrency: AppCurrency) = Amount( + currencySymbol = appCurrency.symbol, + value = fiatValue, + decimals = FIAT_DECIMALS, + type = AmountType.FiatType(appCurrency.code), + ) + private companion object { private const val FIAT_DECIMALS = 2 } diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt index 990e027cdf..72dcac8ac9 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/field/AmountFieldSetMaxAmountTransformer.kt @@ -15,6 +15,7 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.utils.extensions.isZero import com.tangem.utils.transformer.Transformer +import java.math.BigDecimal import java.math.RoundingMode /** @@ -42,9 +43,10 @@ class AmountFieldSetMaxAmountTransformer( val cryptoValue = decimalCryptoValue.parseBigDecimal(cryptoDecimals) val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals, roundingMode = RoundingMode.HALF_UP).orEmpty() - val isLessThanMinimumIfProvided = minAmount?.amount?.let { decimalCryptoValue < it } ?: false + val isLessThanMinimumIfProvided = minAmount?.amount?.let { decimalCryptoValue < it } == true return prevState.copy( isPrimaryButtonEnabled = !isLessThanMinimumIfProvided, + reduceAmountBy = BigDecimal.ZERO, amountTextField = amountTextField.copy( isValuePasted = true, value = cryptoValue, @@ -52,10 +54,7 @@ class AmountFieldSetMaxAmountTransformer( isError = isLessThanMinimumIfProvided, error = when { isLessThanMinimumIfProvided -> { - val minimumAmount = minAmount - ?.amount - ?.format { crypto(cryptoCurrencyStatus.currency) } - .orEmpty() + val minimumAmount = minAmount?.amount.format { crypto(cryptoCurrencyStatus.currency) } resourceReference( R.string.transfer_notification_invalid_minimum_transaction_amount_text, wrappedList(minimumAmount, minimumAmount), diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt index 52758a02cc..9f714127da 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/models/AmountState.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.PersistentList +import java.math.BigDecimal /** Model for amount state */ @Stable @@ -21,6 +22,9 @@ sealed class AmountState { * @param isSegmentedButtonsEnabled indicates if currency switches is enabled * @param amountTextField amount field state * @param appCurrencyCode app currency code + * @param isEditingDisabled indicated whether amount is editable + * @param reduceAmountBy reduces amount to be sent by specified value + * @param isIgnoreReduce ignores reduce amount value */ data class Data( override val isPrimaryButtonEnabled: Boolean, @@ -32,6 +36,9 @@ sealed class AmountState { val isSegmentedButtonsEnabled: Boolean, val amountTextField: AmountFieldModel, val appCurrencyCode: String, + val isEditingDisabled: Boolean = false, + val reduceAmountBy: BigDecimal = BigDecimal.ZERO, + val isIgnoreReduce: Boolean = false, ) : AmountState() data class Empty( diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt index bcac8bb21b..3fd2bd8ad8 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlock.kt @@ -21,10 +21,10 @@ import com.tangem.core.ui.components.ResizableText import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.format.bigdecimal.anyDecimals 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.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.utils.BigDecimalFormatter import java.math.BigDecimal @Composable @@ -33,15 +33,11 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis val amount = amountState.amountTextField val cryptoAmount = formatWithSymbol(amount.value, amount.cryptoAmount.currencySymbol) - val fiatAmount = BigDecimalFormatter.formatFiatAmount( - fiatAmount = amount.fiatAmount.value, - fiatCurrencySymbol = amount.fiatAmount.currencySymbol, - fiatCurrencyCode = amountState.appCurrencyCode, - ) - val backgroundColor = if (isEditingDisabled) { - TangemTheme.colors.button.disabled - } else { - TangemTheme.colors.background.action + val fiatAmount = amount.fiatAmount.value.format { + fiat( + fiatCurrencySymbol = amount.fiatAmount.currencySymbol, + fiatCurrencyCode = amountState.appCurrencyCode, + ) } val (firstAmount, secondAmount) = if (amount.isFiatValue) { @@ -54,7 +50,7 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(backgroundColor) + .background(TangemTheme.colors.background.action) .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick) .padding(TangemTheme.dimens.spacing16), ) { diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt index f2f8e078f9..70d51c2503 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountField.kt @@ -25,12 +25,13 @@ import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTr import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference 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.res.TangemTheme -import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.rememberDecimalFormat import kotlinx.coroutines.delay +@Deprecated("Use AmountField with clicks") @Composable internal fun AmountField(amountField: AmountFieldModel, appCurrencyCode: String) { val decimalFormat = rememberDecimalFormat() @@ -80,6 +81,60 @@ internal fun AmountField(amountField: AmountFieldModel, appCurrencyCode: String) AmountSecondary(amountField, appCurrencyCode) } +@Composable +internal fun AmountField( + amountField: AmountFieldModel, + appCurrencyCode: String, + onValueChange: (String) -> Unit, + onValuePastedTriggerDismiss: () -> Unit, +) { + val decimalFormat = rememberDecimalFormat() + val isFiatValue = amountField.isFiatValue + val currencyCode = if (isFiatValue) appCurrencyCode else null + val (primaryAmount, primaryValue) = if (isFiatValue) { + amountField.fiatAmount to amountField.fiatValue + } else { + amountField.cryptoAmount to amountField.value + } + val requester = remember { FocusRequester() } + + AmountTextField( + value = primaryValue, + decimals = primaryAmount.decimals, + visualTransformation = AmountVisualTransformation( + decimals = primaryAmount.decimals, + symbol = primaryAmount.currencySymbol, + currencyCode = currencyCode, + decimalFormat = decimalFormat, + ), + onValueChange = onValueChange, + keyboardOptions = amountField.keyboardOptions, + keyboardActions = amountField.keyboardActions, + textStyle = TangemTheme.typography.h2.copy( + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ), + isAutoResize = true, + isValuePasted = amountField.isValuePasted, + onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, + modifier = Modifier + .focusRequester(requester) + .padding( + top = TangemTheme.dimens.spacing24, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ) + .requiredHeightIn(min = TangemTheme.dimens.size32), + ) + + LaunchedEffect(key1 = Unit) { + delay(timeMillis = 200) + requester.requestFocus() + } + + AmountSecondary(amountField, appCurrencyCode) +} + @Composable private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: String) { val secondaryAmount = if (amountField.isFiatValue) amountField.cryptoAmount else amountField.fiatAmount @@ -96,11 +151,12 @@ private fun AmountSecondary(amountField: AmountFieldModel, appCurrencyCode: Stri val text = if (amountField.isFiatValue) { secondaryAmount.value.format { crypto(secondaryAmount.currencySymbol, secondaryAmount.decimals) } } else { - BigDecimalFormatter.formatFiatAmount( - fiatAmount = secondaryAmount.value, - fiatCurrencySymbol = secondaryAmount.currencySymbol, - fiatCurrencyCode = appCurrencyCode, - ) + secondaryAmount.value.format { + fiat( + fiatCurrencySymbol = secondaryAmount.currencySymbol, + fiatCurrencyCode = appCurrencyCode, + ) + } } Text( text = text, diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt index 8b9904cc42..104f2f60ae 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountFieldContainer.kt @@ -24,6 +24,8 @@ internal fun LazyListScope.amountField( amountState: AmountState.Data, isBalanceHidden: Boolean, modifier: Modifier = Modifier, + onValueChange: (String) -> Unit, + onValuePastedTriggerDismiss: () -> Unit, ) { item(key = AMOUNT_FIELD_KEY) { Column( @@ -63,6 +65,8 @@ internal fun LazyListScope.amountField( AmountField( amountField = amountState.amountTextField, appCurrencyCode = amountState.appCurrencyCode, + onValueChange = onValueChange, + onValuePastedTriggerDismiss = onValuePastedTriggerDismiss, ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/chooseaddress/ChooseAddressBottomSheet.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/chooseaddress/ChooseAddressBottomSheet.kt similarity index 89% rename from core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/chooseaddress/ChooseAddressBottomSheet.kt rename to common/ui/src/main/java/com/tangem/common/ui/bottomsheet/chooseaddress/ChooseAddressBottomSheet.kt index 098950828e..4054fddac7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/chooseaddress/ChooseAddressBottomSheet.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/chooseaddress/ChooseAddressBottomSheet.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.components.bottomsheets.chooseaddress +package com.tangem.common.ui.bottomsheet.chooseaddress import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.core.ui.R import com.tangem.core.ui.components.SimpleSettingsRow -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/chooseaddress/ChooseAddressBottomSheetConfig.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/chooseaddress/ChooseAddressBottomSheetConfig.kt new file mode 100644 index 0000000000..becc21a447 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/chooseaddress/ChooseAddressBottomSheetConfig.kt @@ -0,0 +1,27 @@ +package com.tangem.common.ui.bottomsheet.chooseaddress + +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.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkAddress +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +class ChooseAddressBottomSheetConfig( + val addressModels: ImmutableList, + val onClick: (AddressModel) -> Unit, +) : TangemBottomSheetConfigContent { + constructor( + asset: TokenReceiveBottomSheetConfig.Asset, + network: Network, + networkAddress: NetworkAddress, + onClick: (AddressModel) -> Unit, + ) : this( + addressModels = networkAddress.availableAddresses + .mapToAddressModels(asset, network) + .toImmutableList(), + onClick = onClick, + ) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt index 4291b55a7f..9476ea0cad 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/permission/GiveTxPermisssionBottomSheet.kt @@ -26,7 +26,7 @@ import com.tangem.common.ui.R import com.tangem.common.ui.bottomsheet.permission.state.* import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.containers.FooterContainer import com.tangem.core.ui.components.inputrow.InputRowDefault diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/AddressMappers.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/AddressMappers.kt new file mode 100644 index 0000000000..42f67f45d9 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/AddressMappers.kt @@ -0,0 +1,76 @@ +package com.tangem.common.ui.bottomsheet.receive + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkAddress + +private const val DSC_ADDRESS_NAME = "DSC" +private const val DEL_ADDRESS_NAME = "Main" + +private const val LEGACY_ADDRESS_NAME = "Legacy" +private const val DEFAULT_ADDRESS_NAME = "Default" + +fun Set.mapToAddressModels(cryptoCurrency: CryptoCurrency) = mapToAddressModels( + stringReference("${cryptoCurrency.name} (${cryptoCurrency.symbol})"), + cryptoCurrency.network, +) + +fun Set.mapToAddressModels( + asset: TokenReceiveBottomSheetConfig.Asset, + network: Network, +): List = when (asset) { + is TokenReceiveBottomSheetConfig.Asset.Currency -> mapToAddressModels( + stringReference("${asset.name} (${asset.symbol})"), + network, + ) + is TokenReceiveBottomSheetConfig.Asset.NFT -> mapToAddressModels( + resourceReference(R.string.common_nft), + network, + ) +} + +private fun Set.mapToAddressModels(name: TextReference, network: Network): List = + this + .sortedBy { it.type.ordinal } + .map { address -> + val displayName = network.getAddressDisplayName(address.type) + AddressModel( + displayName = displayName, + fullName = if (this.size < 2) { + name + } else { + combinedReference( + displayName, + stringReference(" "), + name, + ) + }, + value = address.value, + type = when (address.type) { + NetworkAddress.Address.Type.Primary -> AddressModel.Type.Default + NetworkAddress.Address.Type.Secondary -> AddressModel.Type.Legacy + }, + ) + } + +private fun Network.getAddressDisplayName(addressType: NetworkAddress.Address.Type): TextReference { + return when (id.value) { + "decimal", "decimal/test" -> { + when (addressType) { + NetworkAddress.Address.Type.Primary -> stringReference(value = DEL_ADDRESS_NAME) + NetworkAddress.Address.Type.Secondary -> stringReference(value = DSC_ADDRESS_NAME) + } + } + else -> { + when (addressType) { + NetworkAddress.Address.Type.Primary -> stringReference(value = DEFAULT_ADDRESS_NAME) + NetworkAddress.Address.Type.Secondary -> stringReference(value = LEGACY_ADDRESS_NAME) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/AddressModel.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/AddressModel.kt similarity index 72% rename from core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/AddressModel.kt rename to common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/AddressModel.kt index 8571fdfa35..847cc47d2b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/AddressModel.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/AddressModel.kt @@ -1,9 +1,10 @@ -package com.tangem.core.ui.components.bottomsheets.tokenreceive +package com.tangem.common.ui.bottomsheet.receive import com.tangem.core.ui.extensions.TextReference data class AddressModel( val displayName: TextReference, + val fullName: TextReference, val value: String, val type: Type, ) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/TokenReceiveBottomSheet.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/TokenReceiveBottomSheet.kt similarity index 93% rename from core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/TokenReceiveBottomSheet.kt rename to common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/TokenReceiveBottomSheet.kt index d6f38a3567..f6024e6f93 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/TokenReceiveBottomSheet.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/TokenReceiveBottomSheet.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.components.bottomsheets.tokenreceive +package com.tangem.common.ui.bottomsheet.receive import android.content.res.Configuration import androidx.compose.animation.animateColorAsState @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp import com.tangem.core.res.getStringSafe import com.tangem.core.ui.R import com.tangem.core.ui.components.SecondaryButtonIconStart -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig @@ -67,7 +67,7 @@ private fun TokenReceiveBottomSheetContent(content: TokenReceiveBottomSheetConfi QrCodeContent(content = content, onAddressChange = { selectedAddress = it }) Info( - currency = content.symbol, + symbol = content.asset.displaySymbol, network = content.network, showMemoDisclaimer = content.showMemoDisclaimer, ) @@ -82,7 +82,7 @@ private fun TokenReceiveBottomSheetContent(content: TokenReceiveBottomSheetConfi } @Composable -private fun Info(currency: String, network: String, showMemoDisclaimer: Boolean, modifier: Modifier = Modifier) { +private fun Info(symbol: TextReference, network: String, showMemoDisclaimer: Boolean, modifier: Modifier = Modifier) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -104,7 +104,7 @@ private fun Info(currency: String, network: String, showMemoDisclaimer: Boolean, config = NotificationConfig( title = resourceReference( R.string.receive_bottom_sheet_warning_title, - wrappedList(currency, network), + wrappedList(symbol.resolveReference(), network), ), subtitle = resourceReference(R.string.receive_bottom_sheet_warning_message_description), iconResId = R.drawable.ic_alert_circle_24, @@ -189,9 +189,8 @@ private fun QrCodePage(content: TokenReceiveBottomSheetConfig, qrCodePainter: Pa ) { Text( text = stringResourceSafe( - R.string.receive_bottom_sheet_warning_message, - getName(content = content, index = currentIndex), - content.symbol, + R.string.receive_bottom_sheet_warning_message_compact, + content.addresses[currentIndex].fullName.resolveReference(), content.network, ), color = TangemTheme.colors.text.primary1, @@ -215,15 +214,6 @@ private fun QrCodePage(content: TokenReceiveBottomSheetConfig, qrCodePainter: Pa } } -@Composable -private fun getName(content: TokenReceiveBottomSheetConfig, index: Int): String { - return if (content.addresses.size < 2) { - content.name - } else { - "${content.addresses[index].displayName.resolveReference()} ${content.name}" - } -} - @Composable private fun Buttons( snackbarHostState: SnackbarHostState, @@ -291,12 +281,18 @@ private fun Preview_TokenReceiveBottomSheet( private class TokenReceiveBottomSheetConfigPreviewProvider : PreviewParameterProvider { val address = AddressModel( displayName = stringReference("Address 1"), + fullName = combinedReference( + stringReference("Address 1"), + stringReference(" Stellar"), + ), value = "0xe5178c7d4d0e861ed2e9414e045b501226b0de8d", type = AddressModel.Type.Default, ) private val baseConfig = TokenReceiveBottomSheetConfig( - name = "Stellar", - symbol = "XLM", + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = "Stellar", + symbol = "XLM", + ), network = "Ethereum", addresses = persistentListOf(address), showMemoDisclaimer = false, diff --git a/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/TokenReceiveBottomSheetConfig.kt b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/TokenReceiveBottomSheetConfig.kt new file mode 100644 index 0000000000..382d977c74 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/bottomsheet/receive/TokenReceiveBottomSheetConfig.kt @@ -0,0 +1,51 @@ +package com.tangem.common.ui.bottomsheet.receive + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.R +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.core.ui.extensions.stringReference +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkAddress +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class TokenReceiveBottomSheetConfig( + val asset: Asset, + val network: String, + val addresses: ImmutableList, + val showMemoDisclaimer: Boolean, + val onCopyClick: (String) -> Unit, + val onShareClick: (String) -> Unit, +) : TangemBottomSheetConfigContent { + + constructor( + asset: Asset, + network: Network, + networkAddress: NetworkAddress, + showMemoDisclaimer: Boolean, + onCopyClick: (String) -> Unit, + onShareClick: (String) -> Unit, + ) : this( + asset = asset, + network = network.name, + addresses = networkAddress.availableAddresses + .mapToAddressModels(asset, network) + .toImmutableList(), + showMemoDisclaimer = showMemoDisclaimer, + onCopyClick = onCopyClick, + onShareClick = onShareClick, + ) + + @Immutable + sealed class Asset { + abstract val displaySymbol: TextReference + data class Currency(val name: String, val symbol: String) : Asset() { + override val displaySymbol = stringReference(symbol) + } + data object NFT : Asset() { + override val displaySymbol = resourceReference(R.string.common_nft) + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusBottomSheet.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusBottomSheet.kt index 98c0bfce00..2dc8a4cf7a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusBottomSheet.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusBottomSheet.kt @@ -2,7 +2,7 @@ package com.tangem.common.ui.expressStatus import androidx.compose.runtime.Composable import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.res.TangemTheme diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt index e37d15ae31..d546442104 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt @@ -159,6 +159,11 @@ sealed class NotificationUM(val config: NotificationConfig) { ), subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text), ) + + data object DestinationMemoRequired : Error( + title = resourceReference(id = R.string.send_validation_destination_tag_required_title), + subtitle = resourceReference(id = R.string.send_validation_destination_tag_required_description), + ) } open class Warning( diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index 347ac937ec..008a2665bd 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -15,6 +15,8 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold +import com.tangem.lib.crypto.BlockchainUtils.isTezos import com.tangem.utils.extensions.isZero import com.tangem.utils.extensions.orZero import java.math.BigDecimal @@ -235,6 +237,31 @@ object NotificationsFactory { } } + fun MutableList.addFeeCoverageNotification( + isFeeCoverage: Boolean, + enteredAmountValue: BigDecimal, + sendingValue: BigDecimal, + appCurrency: AppCurrency, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ) { + val cryptoCurrency = cryptoCurrencyStatus.currency + val fiatRate = cryptoCurrencyStatus.value.fiatRate + + val cryptoDiff = enteredAmountValue.minus(sendingValue) + if (isFeeCoverage) { + add( + NotificationUM.Warning.FeeCoverageNotification( + cryptoAmount = cryptoDiff.format { crypto(cryptoCurrency).uncapped() }, + fiatAmount = getFiatString( + value = cryptoDiff, + rate = fiatRate, + appCurrency = appCurrency, + ), + ), + ) + } + } + fun MutableList.addDustWarningNotification( dustValue: BigDecimal?, feeValue: BigDecimal, @@ -326,6 +353,9 @@ object NotificationsFactory { error = validationError, onReduceClick = onReduceClick, ) + is BlockchainSdkError.XRP -> addXRPTransactionValidationError( + error = validationError, + ) null -> minAdaValue?.let { add( NotificationUM.Cardano.MinAdaValueCharged( @@ -406,11 +436,52 @@ object NotificationsFactory { } } + private fun MutableList.addXRPTransactionValidationError(error: BlockchainSdkError.XRP) { + when (error) { + is BlockchainSdkError.XRP.DestinationMemoRequired -> addRequireDestinationFlagErrorNotification() + } + } + fun MutableList.addRentExemptionNotification(rentWarning: CryptoCurrencyWarning.Rent?) { if (rentWarning == null) return add(NotificationUM.Solana.RentInfo(rentWarning)) } + fun MutableList.addHighFeeWarningNotification( + enteredAmountValue: BigDecimal, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ignoreAmountReduce: Boolean, + onReduceClick: ( + reduceAmountBy: BigDecimal, + reduceAmountByDiff: BigDecimal, + notification: Class, + ) -> Unit, + onCloseClick: (Class) -> Unit, + ) { + val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO + val isTezos = isTezos(cryptoCurrencyStatus.currency.network.id.value) + val threshold = getTezosThreshold() + val isTotalBalance = enteredAmountValue >= balance && balance > threshold + if (!ignoreAmountReduce && isTotalBalance && isTezos) { + add( + NotificationUM.Warning.HighFeeError( + currencyName = cryptoCurrencyStatus.currency.name, + amount = threshold.toPlainString(), + onConfirmClick = { + onReduceClick( + threshold, + threshold, + NotificationUM.Warning.HighFeeError::class.java, + ) + }, + onCloseClick = { + onCloseClick(NotificationUM.Warning.HighFeeError::class.java) + }, + ), + ) + } + } + private fun checkDustLimits( feeAmount: BigDecimal, sendingAmount: BigDecimal, @@ -440,4 +511,8 @@ object NotificationsFactory { is CryptoCurrency.Token -> isChangeLowerThanDust } } + + private fun MutableList.addRequireDestinationFlagErrorNotification() { + add(NotificationUM.Error.DestinationMemoRequired) + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index c33c364110..4a8dc44eab 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -14,8 +14,8 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.isZero @@ -121,36 +121,27 @@ class TokenItemStateConverter( companion object { - fun CryptoCurrencyStatus.getFormattedFiatAmount(appCurrency: AppCurrency, includeStaking: Boolean): String { + fun CryptoCurrencyStatus.getFormattedFiatAmount(appCurrency: AppCurrency): String { val fiatAmount = value.fiatAmount ?: return DASH_SIGN - val totalAmount = if (includeStaking) { - val fiatYieldBalance = value.fiatRate?.times(getStakedBalance()).orZero() - - fiatAmount.plus(fiatYieldBalance) - } else { - fiatAmount - } + val fiatYieldBalance = value.fiatRate?.times(getStakedBalance()).orZero() + val totalAmount = fiatAmount.plus(fiatYieldBalance) return totalAmount.format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) } } - fun CryptoCurrencyStatus.getFormattedCryptoAmount(includeStaking: Boolean): String { + fun CryptoCurrencyStatus.getFormattedCryptoAmount(): String { val cryptoAmount = value.amount ?: return DASH_SIGN - val totalAmount = if (includeStaking) { - cryptoAmount.plus(getStakedBalance()) - } else { - cryptoAmount - } + val totalAmount = cryptoAmount.plus(getStakedBalance()) return totalAmount.format { crypto(currency) } } - private fun CryptoCurrencyStatus.getStakedBalance() = - (value.yieldBalance as? YieldBalance.Data)?.getTotalWithRewardsStakingBalance().orZero() + private fun CryptoCurrencyStatus.getStakedBalance() = (value.yieldBalance as? YieldBalance.Data) + ?.getTotalWithRewardsStakingBalance(blockchainId = currency.network.id.value).orZero() private fun createTitleState(currencyStatus: CryptoCurrencyStatus): TokenItemState.TitleState { return when (val value = currencyStatus.value) { @@ -200,11 +191,7 @@ class TokenItemStateConverter( is CryptoCurrencyStatus.NoAccount, -> { TokenItemState.Subtitle2State.TextContent( - text = status.getFormattedCryptoAmount( - includeStaking = BlockchainUtils.isIncludeStakingTotalBalance( - status.currency.network.id.value, - ), - ), + text = status.getFormattedCryptoAmount(), isFlickering = status.value.isFlickering(), ) } @@ -227,12 +214,7 @@ class TokenItemStateConverter( is CryptoCurrencyStatus.NoAccount, -> { TokenItemState.FiatAmountState.Content( - text = status.getFormattedFiatAmount( - appCurrency = appCurrency, - includeStaking = BlockchainUtils.isIncludeStakingTotalBalance( - status.currency.network.id.value, - ), - ), + text = status.getFormattedFiatAmount(appCurrency = appCurrency), isFlickering = status.value.isFlickering(), icons = buildList { if (!status.getStakedBalance().isZero()) { diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt index 025a6c1038..8c12f046df 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt @@ -7,7 +7,6 @@ open class AnalyticsEvent( val category: String, val event: String, var params: Map = mapOf(), - val error: Throwable? = null, ) { val id: String = "[$category] $event" diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt index c623358a84..8f2960f7a1 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt @@ -3,8 +3,7 @@ package com.tangem.core.analytics.models sealed class Basic( event: String, params: Map = mapOf(), - error: Throwable? = null, -) : AnalyticsEvent("Basic", event, params, error) { +) : AnalyticsEvent("Basic", event, params) { class CardWasScanned( source: AnalyticsParam.ScreensSources, @@ -73,11 +72,6 @@ sealed class Basic( } } - class ScanError(error: Throwable) : Basic( - event = "Scan", - error = error, - ) - class ButtonSupport(source: AnalyticsParam.ScreensSources) : Basic( event = "Request Support", params = mapOf( diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/ExceptionAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/ExceptionAnalyticsEvent.kt new file mode 100644 index 0000000000..c2af9763c7 --- /dev/null +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/ExceptionAnalyticsEvent.kt @@ -0,0 +1,6 @@ +package com.tangem.core.analytics.models + +class ExceptionAnalyticsEvent( + val exception: Throwable, + val params: Map = mapOf(), +) \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt index 239573bed5..081a583c06 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt @@ -2,6 +2,7 @@ package com.tangem.core.analytics import com.tangem.core.analytics.api.* import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.utils.coroutines.FeatureCoroutineExceptionHandler import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -16,7 +17,9 @@ interface GlobalAnalyticsEventHandler : AnalyticsEventHandler, AnalyticsHandlerHolder, AnalyticsFilterHolder, - ParamsInterceptorHolder + ParamsInterceptorHolder, + AnalyticsErrorHandler, + AnalyticsExceptionHandler object Analytics : GlobalAnalyticsEventHandler { @@ -72,6 +75,25 @@ object Analytics : GlobalAnalyticsEventHandler { } } + override fun sendErrorEvent(event: AnalyticsEvent) { + analyticsScope.launch { + event.params = applyParamsInterceptors(event) + analyticsMutex.withLock { + analyticsHandlers.filterIsInstance() + .forEach { handler -> handler.sendErrorEvent(event) } + } + } + } + + override fun sendException(event: ExceptionAnalyticsEvent) { + analyticsScope.launch { + analyticsMutex.withLock { + analyticsHandlers.filterIsInstance() + .forEach { it.sendException(event) } + } + } + } + private suspend fun applyParamsInterceptors(event: AnalyticsEvent): MutableMap { val interceptedParams = event.params.toMutableMap() analyticsMutex.withLock { diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/api/ClientApi.kt b/core/analytics/src/main/java/com/tangem/core/analytics/api/ClientApi.kt index f44a0133cd..523ac41b71 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/api/ClientApi.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/api/ClientApi.kt @@ -8,5 +8,9 @@ interface EventLogger { } interface ErrorEventLogger { - fun logErrorEvent(error: Throwable, params: Map = emptyMap()) + fun logErrorEvent(event: String, params: Map = emptyMap()) +} + +interface ExceptionLogger { + fun logException(error: Throwable, params: Map = emptyMap()) } \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/api/EventHandlerApi.kt b/core/analytics/src/main/java/com/tangem/core/analytics/api/EventHandlerApi.kt index 7bedfebff3..6c98379d6d 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/api/EventHandlerApi.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/api/EventHandlerApi.kt @@ -1,15 +1,25 @@ package com.tangem.core.analytics.api import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent /** -[REDACTED_AUTHOR] + * Sends usual analytics events */ interface AnalyticsEventHandler { fun send(event: AnalyticsEvent) } +interface AnalyticsErrorHandler { + fun sendErrorEvent(event: AnalyticsEvent) +} + +interface AnalyticsExceptionHandler { + fun sendException(event: ExceptionAnalyticsEvent) +} + interface AnalyticsHandler : AnalyticsEventHandler { + fun id(): String fun send(eventId: String, params: Map = emptyMap()) @@ -19,10 +29,6 @@ interface AnalyticsHandler : AnalyticsEventHandler { } } -interface ErrorEventHandler { - fun send(error: Throwable, params: Map = emptyMap()) -} - interface AnalyticsHandlerHolder { fun addHandler(name: String, handler: AnalyticsHandler) fun removeHandler(name: String): AnalyticsHandler? diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/di/AnalyticsModule.kt b/core/analytics/src/main/java/com/tangem/core/analytics/di/AnalyticsModule.kt index 18a95d3a02..de53ef6a99 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/di/AnalyticsModule.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/di/AnalyticsModule.kt @@ -1,7 +1,9 @@ package com.tangem.core.analytics.di import com.tangem.core.analytics.Analytics +import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.api.ParamsInterceptorHolder import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.domain.analytics.repository.AnalyticsRepository @@ -21,6 +23,18 @@ internal object AnalyticsModule { return Analytics // todo replace after refactoring calling Analytics in whole project } + @Singleton + @Provides + fun provideAnalyticsErrorHandler(): AnalyticsErrorHandler { + return Analytics + } + + @Singleton + @Provides + fun provideAnalyticsExceptionHandler(): AnalyticsExceptionHandler { + return Analytics + } + @Singleton @Provides fun provideParamsInterceptorHolder(): ParamsInterceptorHolder { 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 67b61d21e3..c0d2c19ebd 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 @@ -3,50 +3,26 @@ "name": "NEW_CARD_SCANNING_ENABLED", "version": "undefined" }, - { - "name": "WC_SOLANA_TX_SIGN_ENABLED", - "version": "5.18.0" - }, - { - "name": "IS_ETHEREUM_EIP_1559_ENABLED", - "version": "5.17.0" - }, { "name": "ONRAMP_ENABLED", "version": "undefined" }, - { - "name": "MAIN_ACTION_BUTTONS_ENABLED", - "version": "5.19.0" - }, { "name": "ONBOARDING_CODE_REFACTORING_ENABLED", "version": "5.22.0" }, { "name": "NAVIGATION_REFACTORING", - "version": "undefined" - }, - { - "name": "SWAP_STORIES_ENABLED", - "version": "5.21.0" + "version": "5.23.0" }, { "name": "VISA_ONBOARDING_ENABLED", "version": "undefined" }, - { - "name": "ONRAMP_HOT_TOKENS_ENABLED", - "version": "5.21.0" - }, { "name": "STAKING_TON_ENABLED", "version": "undefined" }, - { - "name": "BALANCES_CACHING_ENABLED", - "version": "5.21.0" - }, { "name": "NFT_ENABLED", "version": "undefined" @@ -65,6 +41,26 @@ }, { "name": "NOTE_REFACTORING_ENABLED", + "version": "5.23.0" + }, + { + "name": "NEW_ATTESTATION_ENABLED", + "version": "5.23.0" + }, + { + "name": "TWIN_REFACTORING_ENABLED", + "version": "5.23.0" + }, + { + "name": "SEND_V2_ENABLED", + "version": "5.23.0" + }, + { + "name": "WALLET_CONNECT_REDESIGN_ENABLED", "version": "undefined" + }, + { + "name": "NETWORKS_LOADING_REFACTORING_ENABLED", + "version": "5.23.0" } ] diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 380fdce973..0efd84702a 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(projects.domain.onramp.models) implementation(projects.domain.models) implementation(projects.domain.nft.models) + implementation(projects.domain.walletConnect.models) /** Tangem libraries */ implementation(tangemDeps.blockchain) @@ -70,9 +71,7 @@ dependencies { /** Chucker */ debugImplementation(deps.chucker) - debugPGImplementation(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/config/ApiConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/ApiConfig.kt index 28d4220428..66924d6e19 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 @@ -26,6 +26,7 @@ sealed class ApiConfig { StakeKit, TangemVisaAuth, TangemVisa, + TangemCardSdk, } private fun initializeId(): ID { @@ -35,15 +36,14 @@ sealed class ApiConfig { is StakeKit -> ID.StakeKit is TangemVisaAuth -> ID.TangemVisaAuth is TangemVisa -> ID.TangemVisa + is TangemCardSdk -> ID.TangemCardSdk } } companion object { internal const val DEBUG_BUILD_TYPE = "debug" - internal const val DEBUG_PG_BUILD_TYPE = "debugPG" 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/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt index 8a850c20ea..3ad70b9000 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 @@ -69,12 +69,10 @@ internal class Express( fun getInitialEnvironment(): ApiEnvironment { return when (BuildConfig.BUILD_TYPE) { DEBUG_BUILD_TYPE, - DEBUG_PG_BUILD_TYPE, -> ApiEnvironment.DEV 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/common/config/TangemCardSdk.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemCardSdk.kt new file mode 100644 index 0000000000..49edb4a7d8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemCardSdk.kt @@ -0,0 +1,26 @@ +package com.tangem.datasource.api.common.config + +/** + * Tangem API [ApiConfig] that used in CardSDK + * +[REDACTED_AUTHOR] + */ +internal class TangemCardSdk : ApiConfig() { + + override val defaultEnvironment: ApiEnvironment = ApiEnvironment.PROD + + override val environmentConfigs: List = listOf( + createDevEnvironment(), + createProdEnvironment(), + ) + + private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.DEV, + baseUrl = "[REDACTED_ENV_URL]", + ) + + private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.tangem-tech.com/", + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt index dd567e31f4..e15e7ce4e3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt @@ -19,13 +19,13 @@ internal class TangemTech( private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.PROD, - baseUrl = "https://api.tangem-tech.com/v1/", + baseUrl = "https://api.tangem.org/v1/", headers = createHeaders(), ) private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( environment = ApiEnvironment.DEV, - baseUrl = "https://devapi.tangem-tech.com/v1/", + baseUrl = "[REDACTED_ENV_URL]", headers = createHeaders(), ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt index 880a57f286..e7c3054219 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/ApiConfigsManager.kt @@ -11,7 +11,7 @@ import com.tangem.datasource.api.common.config.ApiEnvironmentConfig interface ApiConfigsManager { /** Initialize resources */ - suspend fun initialize() {} + fun initialize() {} /** Get environment config by [id] */ fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt index 4f4efdd955..7d60844b81 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/managers/DevApiConfigsManager.kt @@ -9,6 +9,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectMap import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.* /** @@ -28,7 +29,7 @@ internal class DevApiConfigsManager( private val _apiConfigs = MutableStateFlow(value = apiConfigs.associateWith { it.defaultEnvironment }) - override suspend fun initialize() { + override fun initialize() { // We can't use appPreferencesStore.getObjectMap as base flow, // because we should keep possibility to work with configs synchronous. // See [getBaseUrl] @@ -42,7 +43,7 @@ internal class DevApiConfigsManager( } } } - .launchIn(CoroutineScope(dispatchers.main)) + .launchIn(CoroutineScope(SupervisorJob() + dispatchers.main)) } override fun getEnvironmentConfig(id: ApiConfig.ID): ApiEnvironmentConfig { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapter.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapter.kt index 885b52ca28..6f9fbc020a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapter.kt @@ -1,16 +1,18 @@ package com.tangem.datasource.api.common.response +import com.tangem.core.analytics.api.AnalyticsErrorHandler import retrofit2.Call import retrofit2.CallAdapter import java.lang.reflect.Type internal class ApiResponseCallAdapter( private val resultType: Type, + private val analyticsErrorHandler: AnalyticsErrorHandler, ) : CallAdapter>> { override fun responseType(): Type = resultType override fun adapt(call: Call): Call> { - return ApiResponseCallDelegate(call) + return ApiResponseCallDelegate(call, analyticsErrorHandler) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapterFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapterFactory.kt index 8f8c65e92d..b3bd217b7b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapterFactory.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallAdapterFactory.kt @@ -1,12 +1,15 @@ package com.tangem.datasource.api.common.response +import com.tangem.core.analytics.api.AnalyticsErrorHandler import retrofit2.Call import retrofit2.CallAdapter import retrofit2.Retrofit import java.lang.reflect.ParameterizedType import java.lang.reflect.Type -class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory() { +class ApiResponseCallAdapterFactory private constructor( + private val analyticsErrorHandler: AnalyticsErrorHandler, +) : CallAdapter.Factory() { override fun get(returnType: Type, annotations: Array, retrofit: Retrofit): CallAdapter<*, *>? { if (getRawType(returnType) != Call::class.java) { @@ -19,11 +22,11 @@ class ApiResponseCallAdapterFactory private constructor() : CallAdapter.Factory( } val resultType = getParameterUpperBound(0, callType as ParameterizedType) - return ApiResponseCallAdapter(resultType) + return ApiResponseCallAdapter(resultType, analyticsErrorHandler) } companion object { - fun create() = ApiResponseCallAdapterFactory() + fun create(analyticsErrorHandler: AnalyticsErrorHandler) = ApiResponseCallAdapterFactory(analyticsErrorHandler) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt index 919be8e848..50ee235c7b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseCallDelegate.kt @@ -1,5 +1,6 @@ package com.tangem.datasource.api.common.response +import com.tangem.core.analytics.api.AnalyticsErrorHandler import okhttp3.Request import okio.Timeout import retrofit2.Call @@ -9,6 +10,7 @@ import timber.log.Timber internal class ApiResponseCallDelegate( private val wrappedCall: Call, + private val analyticsErrorHandler: AnalyticsErrorHandler, ) : Call> { override fun enqueue(callback: Callback>) { @@ -16,7 +18,7 @@ internal class ApiResponseCallDelegate( } override fun execute(): Response> = throw NotImplementedError() - override fun clone(): Call> = ApiResponseCallDelegate(wrappedCall.clone()) + override fun clone(): Call> = ApiResponseCallDelegate(wrappedCall.clone(), analyticsErrorHandler) override fun request(): Request = wrappedCall.request() override fun timeout(): Timeout = wrappedCall.timeout() override fun isExecuted(): Boolean = wrappedCall.isExecuted @@ -30,7 +32,7 @@ internal class ApiResponseCallDelegate( ) : Callback { override fun onResponse(call: Call, response: Response) { - val safeResponse = response.toSafeApiResponse() + val safeResponse = response.toSafeApiResponse(analyticsErrorHandler) responseCallback.onResponse(this@ApiResponseCallDelegate, Response.success(safeResponse)) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt index 22162b76b4..10ec8add5d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ApiResponseError.kt @@ -19,51 +19,51 @@ sealed class ApiResponseError : Exception() { ) : ApiResponseError() { // region Error Codes - enum class Code(val code: Int) { + enum class Code(val numericCode: Int) { // 4xx Server Errors - BAD_REQUEST(code = 400), - UNAUTHORIZED(code = 401), - PAYMENT_REQUIRED(code = 402), - FORBIDDEN(code = 403), - NOT_FOUND(code = 404), - METHOD_NOT_ALLOWED(code = 405), - NOT_ACCEPTABLE(code = 406), - PROXY_AUTHENTICATION_REQUIRED(code = 407), - REQUEST_TIMEOUT(code = 408), - CONFLICT(code = 409), - GONE(code = 410), - LENGTH_REQUIRED(code = 411), - PRECONDITION_FAILED(code = 412), - PAYLOAD_TOO_LARGE(code = 413), - URI_TOO_LONG(code = 414), - UNSUPPORTED_MEDIA_TYPE(code = 415), - RANGE_NOT_SATISFIABLE(code = 416), - EXPECTATION_FAILED(code = 417), - IM_A_TEAPOT(code = 418), // Not an error, but an April Fools' joke from RFC 2324 - UNPROCESSABLE_ENTITY(code = 422), - LOCKED(code = 423), - FAILED_DEPENDENCY(code = 424), - TOO_EARLY(code = 425), - UPGRADE_REQUIRED(code = 426), - PRECONDITION_REQUIRED(code = 428), - TOO_MANY_REQUESTS(code = 429), - REQUEST_HEADER_FIELDS_TOO_LARGE(code = 431), - UNAVAILABLE_FOR_LEGAL_REASONS(code = 451), + BAD_REQUEST(numericCode = 400), + UNAUTHORIZED(numericCode = 401), + PAYMENT_REQUIRED(numericCode = 402), + FORBIDDEN(numericCode = 403), + NOT_FOUND(numericCode = 404), + METHOD_NOT_ALLOWED(numericCode = 405), + NOT_ACCEPTABLE(numericCode = 406), + PROXY_AUTHENTICATION_REQUIRED(numericCode = 407), + REQUEST_TIMEOUT(numericCode = 408), + CONFLICT(numericCode = 409), + GONE(numericCode = 410), + LENGTH_REQUIRED(numericCode = 411), + PRECONDITION_FAILED(numericCode = 412), + PAYLOAD_TOO_LARGE(numericCode = 413), + URI_TOO_LONG(numericCode = 414), + UNSUPPORTED_MEDIA_TYPE(numericCode = 415), + RANGE_NOT_SATISFIABLE(numericCode = 416), + EXPECTATION_FAILED(numericCode = 417), + IM_A_TEAPOT(numericCode = 418), // Not an error, but an April Fools' joke from RFC 2324 + UNPROCESSABLE_ENTITY(numericCode = 422), + LOCKED(numericCode = 423), + FAILED_DEPENDENCY(numericCode = 424), + TOO_EARLY(numericCode = 425), + UPGRADE_REQUIRED(numericCode = 426), + PRECONDITION_REQUIRED(numericCode = 428), + TOO_MANY_REQUESTS(numericCode = 429), + REQUEST_HEADER_FIELDS_TOO_LARGE(numericCode = 431), + UNAVAILABLE_FOR_LEGAL_REASONS(numericCode = 451), // 5xx Server Errors - INTERNAL_SERVER_ERROR(code = 500), - NOT_IMPLEMENTED(code = 501), - BAD_GATEWAY(code = 502), - SERVICE_UNAVAILABLE(code = 503), - GATEWAY_TIMEOUT(code = 504), - HTTP_VERSION_NOT_SUPPORTED(code = 505), - VARIANT_ALSO_NEGOTIATES(code = 506), - INSUFFICIENT_STORAGE(code = 507), - LOOP_DETECTED(code = 508), - NOT_EXTENDED(code = 510), - NETWORK_AUTHENTICATION_REQUIRED(code = 511), + INTERNAL_SERVER_ERROR(numericCode = 500), + NOT_IMPLEMENTED(numericCode = 501), + BAD_GATEWAY(numericCode = 502), + SERVICE_UNAVAILABLE(numericCode = 503), + GATEWAY_TIMEOUT(numericCode = 504), + HTTP_VERSION_NOT_SUPPORTED(numericCode = 505), + VARIANT_ALSO_NEGOTIATES(numericCode = 506), + INSUFFICIENT_STORAGE(numericCode = 507), + LOOP_DETECTED(numericCode = 508), + NOT_EXTENDED(numericCode = 510), + NETWORK_AUTHENTICATION_REQUIRED(numericCode = 511), ; - override fun toString(): String = "$code - $name" + override fun toString(): String = "$numericCode - $name" companion object { val values = values() diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt index f696275bc3..1699dbc089 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/ResponseExt.kt @@ -1,5 +1,7 @@ package com.tangem.datasource.api.common.response +import com.tangem.core.analytics.api.AnalyticsErrorHandler +import com.tangem.datasource.api.common.response.analytics.ApiErrorEvent import kotlinx.coroutines.TimeoutCancellationException import retrofit2.Response import timber.log.Timber @@ -9,18 +11,20 @@ import java.net.UnknownHostException import java.util.concurrent.TimeoutException import javax.net.ssl.SSLHandshakeException -internal fun Response.toSafeApiResponse(): ApiResponse { +internal fun Response.toSafeApiResponse(analyticsErrorHandler: AnalyticsErrorHandler): ApiResponse { val body = body() return if (isSuccessful && body != null) { apiSuccess(body) } else { val code = ApiResponseError.HttpException.Code.values - .firstOrNull { it.code == code() } + .firstOrNull { it.numericCode == code() } val e = try { if (code == null) { ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}")) } else { + sendHttpError(code, analyticsErrorHandler) + ApiResponseError.HttpException(code, message(), errorBody()?.string()) } } catch (e: Exception) { @@ -32,6 +36,21 @@ internal fun Response.toSafeApiResponse(): ApiResponse { } } +private fun Response.sendHttpError( + code: ApiResponseError.HttpException.Code, + analyticsErrorHandler: AnalyticsErrorHandler, +) { + val fullRequestUrl = raw().request.url.toUrl() + val shortUrl = fullRequestUrl.authority + fullRequestUrl.path + analyticsErrorHandler.sendErrorEvent( + ApiErrorEvent( + endpoint = shortUrl, + code = code.numericCode, + message = errorBody()?.string().orEmpty(), + ), + ) +} + internal fun Throwable.toApiError(): ApiResponseError = when (this) { is ConnectException, is UnknownHostException, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/response/analytics/ApiErrorEvent.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/analytics/ApiErrorEvent.kt new file mode 100644 index 0000000000..c01da30063 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/response/analytics/ApiErrorEvent.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.api.common.response.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +internal data class ApiErrorEvent( + val endpoint: String, + val code: Int, + val message: String, +) : AnalyticsEvent( + category = "Tangem API Service", + event = "Exception", + params = mapOf( + "Endpoint" to endpoint, + "Code" to code.toString(), + "Message" to message, + ), +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt index 3dbf406f16..b2af512cda 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/models/response/TokenMarketListResponse.kt @@ -20,7 +20,7 @@ data class TokenMarketListResponse( @Json(name = "name") val name: String, @Json(name = "symbol") val symbol: String, @Json(name = "current_price") val currentPrice: BigDecimal, - @Json(name = "price_change_percentage") val priceChangePercentage: PriceChangePercentage, + @Json(name = "price_change_percentage") val priceChangePercentage: PriceChangePercentage?, @Json(name = "market_rating") val marketRating: Int?, @Json(name = "market_cap") val marketCap: BigDecimal?, @Json(name = "is_under_market_cap_limit") val isUnderMarketCapLimit: Boolean?, @@ -28,9 +28,9 @@ data class TokenMarketListResponse( @JsonClass(generateAdapter = true) data class PriceChangePercentage( - @Json(name = "24h") val h24: BigDecimal, - @Json(name = "1w") val week1: BigDecimal, - @Json(name = "30d") val day30: BigDecimal, + @Json(name = "24h") val h24: BigDecimal?, + @Json(name = "1w") val week1: BigDecimal?, + @Json(name = "30d") val day30: BigDecimal?, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt index a83ebdd931..121730cdcf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/stakekit/models/response/model/YieldBalanceWrapperDTO.kt @@ -3,6 +3,7 @@ package com.tangem.datasource.api.stakekit.models.response.model import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.tangem.datasource.api.stakekit.models.request.Address +import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO.PendingAction.PendingActionArgs.Amount import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionTypeDTO import org.joda.time.DateTime import java.math.BigDecimal @@ -31,6 +32,8 @@ data class BalanceDTO( val pricePerShare: BigDecimal, @Json(name = "pendingActions") val pendingActions: List, + @Json(name = "pendingActionConstraints") + val pendingActionConstraints: List?, @Json(name = "token") val tokenDTO: TokenDTO, @Json(name = "validatorAddress") @@ -99,7 +102,7 @@ data class BalanceDTO( @JsonClass(generateAdapter = true) data class Amount( @Json(name = "required") - val required: Boolean, + val required: Boolean = true, @Json(name = "minimum") val minimum: BigDecimal?, @Json(name = "maximum") @@ -136,6 +139,14 @@ data class BalanceDTO( } } + @JsonClass(generateAdapter = true) + data class PendingActionConstraints( + @Json(name = "type") + val type: StakingActionTypeDTO, + @Json(name = "amount") + val amountArg: Amount, + ) + @JsonClass(generateAdapter = true) data class Required( @Json(name = "required") 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 20d50bcd28..5035774b7f 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 @@ -2,9 +2,6 @@ package com.tangem.datasource.di import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.config.* -import com.tangem.datasource.api.common.config.Express -import com.tangem.datasource.api.common.config.StakeKit -import com.tangem.datasource.api.common.config.TangemTech import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider @@ -48,4 +45,8 @@ internal object ApiConfigsModule { @Provides @IntoSet fun provideTangemVisaConfig(appVersionProvider: AppVersionProvider): ApiConfig = TangemVisa(appVersionProvider) + + @Provides + @IntoSet + fun provideTangemCardSdkConfig(): ApiConfig = TangemCardSdk() } \ No newline at end of file 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 29dbfa7630..dd14d5f63a 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 @@ -2,6 +2,7 @@ package com.tangem.datasource.di 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.config.ApiConfig import com.tangem.datasource.api.common.config.ApiConfigs @@ -61,6 +62,7 @@ internal object NetworkModule { fun provideExpressApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, apiConfigsManager: ApiConfigsManager, appLogsStore: AppLogsStore, ): TangemExpressApi { @@ -69,6 +71,7 @@ internal object NetworkModule { moshi = moshi, context = context, apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, clientBuilder = { addInterceptor( NetworkLogsSaveInterceptor(appLogsStore), @@ -83,6 +86,7 @@ internal object NetworkModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, apiConfigsManager: ApiConfigsManager, + analyticsErrorHandler: AnalyticsErrorHandler, appLogsStore: AppLogsStore, ): StakeKitApi { return createApi( @@ -90,6 +94,7 @@ internal object NetworkModule { moshi = moshi, context = context, apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, timeouts = Timeouts( callTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, connectTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, @@ -109,6 +114,7 @@ internal object NetworkModule { fun provideOnrampApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, apiConfigsManager: ApiConfigsManager, appLogsStore: AppLogsStore, ): OnrampApi { @@ -117,6 +123,7 @@ internal object NetworkModule { moshi = moshi, context = context, apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, clientBuilder = { addInterceptor( NetworkLogsSaveInterceptor(appLogsStore), @@ -130,6 +137,7 @@ internal object NetworkModule { fun provideTangemTechApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, apiConfigsManager: ApiConfigsManager, ): TangemTechApi { return createApi( @@ -137,6 +145,7 @@ internal object NetworkModule { moshi = moshi, context = context, apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, clientBuilder = { applyTimeoutAnnotations() }, ) } @@ -147,6 +156,7 @@ internal object NetworkModule { fun provideTangemTechApiV2( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, appVersionProvider: AppVersionProvider, ): TangemTechApiV2 { return provideTangemTechApiInternal( @@ -154,6 +164,7 @@ internal object NetworkModule { context = context, appVersionProvider = appVersionProvider, baseUrl = PROD_V2_TANGEM_TECH_BASE_URL, + analyticsErrorHandler = analyticsErrorHandler, ) } @@ -162,6 +173,7 @@ internal object NetworkModule { fun provideTangemTechMarketsApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, apiConfigsManager: ApiConfigsManager, ): TangemTechMarketsApi { return createApi( @@ -169,6 +181,7 @@ internal object NetworkModule { moshi = moshi, context = context, apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, clientBuilder = { this.callTimeout(TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS) .connectTimeout(TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, TimeUnit.SECONDS) @@ -183,6 +196,7 @@ internal object NetworkModule { fun provideTangemVisaAuthApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, apiConfigsManager: ApiConfigsManager, appLogsStore: AppLogsStore, ): TangemVisaAuthApi { @@ -191,6 +205,7 @@ internal object NetworkModule { moshi = moshi, context = context, apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, clientBuilder = { addInterceptor( NetworkLogsSaveInterceptor(appLogsStore), @@ -204,6 +219,7 @@ internal object NetworkModule { fun provideTangemVisaApi( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, + analyticsErrorHandler: AnalyticsErrorHandler, apiConfigsManager: ApiConfigsManager, appLogsStore: AppLogsStore, ): TangemVisaApi { @@ -212,6 +228,7 @@ internal object NetworkModule { moshi = moshi, context = context, apiConfigsManager = apiConfigsManager, + analyticsErrorHandler = analyticsErrorHandler, clientBuilder = { addInterceptor( NetworkLogsSaveInterceptor(appLogsStore), @@ -226,6 +243,7 @@ internal object NetworkModule { context: Context, appVersionProvider: AppVersionProvider, baseUrl: String, + analyticsErrorHandler: AnalyticsErrorHandler, timeouts: Timeouts = Timeouts(), requestHeaders: List = listOf(AppVersionPlatformHeaders(appVersionProvider)), ): T { @@ -257,7 +275,7 @@ internal object NetworkModule { return Retrofit.Builder() .addConverterFactory(MoshiConverterFactory.create(moshi)) - .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) + .addCallAdapterFactory(ApiResponseCallAdapterFactory.create(analyticsErrorHandler)) .baseUrl(baseUrl) .client(client) .build() @@ -269,6 +287,7 @@ internal object NetworkModule { moshi: Moshi, context: Context, apiConfigsManager: ApiConfigsManager, + analyticsErrorHandler: AnalyticsErrorHandler, timeouts: Timeouts = Timeouts(), clientBuilder: OkHttpClient.Builder.() -> OkHttpClient.Builder = { this }, ): T { @@ -276,7 +295,7 @@ internal object NetworkModule { return Retrofit.Builder() .addConverterFactory(MoshiConverterFactory.create(moshi)) - .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) + .addCallAdapterFactory(ApiResponseCallAdapterFactory.create(analyticsErrorHandler)) .baseUrl(environmentConfig.baseUrl) .client( OkHttpClient.Builder() diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworksStatusesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworksStatusesStoreModule.kt index 62010f5c24..f0cabaf025 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworksStatusesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworksStatusesStoreModule.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 @@ -19,28 +20,38 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal object NetworksStatusesStoreModule { + @Singleton @Provides - fun provideNetworksStatusesStore( + fun providePersistenceNetworksStatusesStore( @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, + ): DataStore>> { + return DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(valueTypes = setTypes()), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = "networks_statuses") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + } + + @Singleton + @Provides + fun provideNetworksStatusesStore( + persistenceNetworksStatusesStore: DataStore>>, ): NetworksStatusesStore { return DefaultNetworksStatusesStore( runtimeDataStore = RuntimeDataStore(), - persistenceDataStore = DataStoreFactory.create( - serializer = MoshiDataStoreSerializer( - moshi = moshi, - types = mapWithStringKeyTypes(valueTypes = setTypes()), - defaultValue = emptyMap(), - ), - produceFile = { context.dataStoreFile(fileName = "networks_statuses") }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), - ), + persistenceDataStore = persistenceNetworksStatusesStore, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt index 655f4a41b3..3d632eaf97 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/StakingStoreModule.kt @@ -5,10 +5,12 @@ import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO import com.tangem.datasource.local.datastore.RuntimeDataStore import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.token.* import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.listTypes import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.datasource.utils.setTypes import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -27,8 +29,22 @@ internal object StakingStoreModule { @Provides @Singleton - fun provideStakingTokensStore(): StakingYieldsStore { - return DefaultStakingYieldsStore(dataStore = RuntimeDataStore()) + fun provideStakingTokensStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + dispatchers: CoroutineDispatcherProvider, + ): StakingYieldsStore { + return DefaultStakingYieldsStore( + dataStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = listTypes(), + defaultValue = emptyList(), + ), + produceFile = { context.dataStoreFile(fileName = "yields_cache") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ), + ) } @Provides diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt new file mode 100644 index 0000000000..4c5f149013 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/WalletConnectModule.kt @@ -0,0 +1,45 @@ +package com.tangem.datasource.di + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.squareup.moshi.Moshi +import com.tangem.datasource.local.walletconnect.DefaultWalletConnectStore +import com.tangem.datasource.local.walletconnect.WalletConnectStore +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.setTypes +import com.tangem.domain.walletconnect.model.WcSessionDTO +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object WalletConnectModule { + + @Provides + @Singleton + fun provideWalletConnectStore( + @NetworkMoshi moshi: Moshi, + @ApplicationContext context: Context, + dispatchers: CoroutineDispatcherProvider, + ): WalletConnectStore { + return DefaultWalletConnectStore( + persistenceStore = DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = setTypes(), + defaultValue = emptySet(), + ), + produceFile = { context.dataStoreFile(fileName = "wallet_connect_sessions") }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ), + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeSharedStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeSharedStore.kt index 1636a37cd0..d32fa5215c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeSharedStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/datastore/RuntimeSharedStore.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Runtime shared store @@ -42,6 +44,7 @@ interface RuntimeSharedStore { operator fun invoke(): RuntimeSharedStore = object : RuntimeSharedStore { private val flow = MutableSharedFlow(replay = 1) + private val updateMutex = Mutex() init { flow.tryEmit(value = null) @@ -58,10 +61,13 @@ interface RuntimeSharedStore { } override suspend fun update(default: T, function: (T) -> T) { - val storedData = flow.firstOrNull() ?: default - val updatedData = function(storedData) + // required to synchronize get (firstOrNull) and update (emit) operations + updateMutex.withLock { + val storedData = flow.firstOrNull() ?: default + val updatedData = function(storedData) - flow.emit(value = updatedData) + flow.emit(value = updatedData) + } } } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt index ac6d80ef03..60e30ca5e7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt @@ -4,18 +4,15 @@ import android.content.Context import com.tangem.datasource.BuildConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.joda.time.DateTime import org.joda.time.format.DateTimeFormatterBuilder import timber.log.Timber -import java.io.BufferedWriter -import java.io.File -import java.io.FileWriter +import java.io.* +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream import javax.inject.Inject import javax.inject.Singleton @@ -30,7 +27,7 @@ import javax.inject.Singleton @Singleton class AppLogsStore @Inject constructor( @ApplicationContext private val applicationContext: Context, - dispatchers: CoroutineDispatcherProvider, + private val dispatchers: CoroutineDispatcherProvider, ) { private val scope = CoroutineScope( @@ -38,8 +35,10 @@ class AppLogsStore @Inject constructor( CoroutineExceptionHandler { _, error -> Timber.e("AppLogsStore.scope is failed $error") }, ) private val mutex = Mutex() + private val zipMutex = Mutex() - private val file = File(applicationContext.filesDir, NEW_LOG_FILE_NAME) + private val file = File(applicationContext.filesDir, PERMITTED_FILE_NAME) + private val fileZip = File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP) private val formatter = DateTimeFormatterBuilder() .appendDayOfMonth(2) @@ -58,6 +57,16 @@ class AppLogsStore @Inject constructor( /** Get log file */ fun getFile(): File? = if (file.exists()) file else null + suspend fun getZipFile(): File? { + return zipMutex.withLock { + if (file.exists()) { + zip(listOf(file), fileZip) + } else { + null + } + } + } + /** Save log [message] */ fun saveLogMessage(tag: String, message: String) { // Temporally logs are not saved in prod environment @@ -133,8 +142,41 @@ class AppLogsStore @Inject constructor( } } + @Suppress("NestedBlockDepth") + private suspend fun zip(filesToCompress: List, outputZipFile: File): File? { + return withContext(dispatchers.io) { + if (outputZipFile.exists() && !outputZipFile.delete()) { + return@withContext null + } + + val buffer = ByteArray(BUFFER_SIZE) + + FileOutputStream(outputZipFile).use { fos -> + ZipOutputStream(fos).use { zos -> + filesToCompress.forEach { file -> + FileInputStream(file).use { inStream -> + val ze = ZipEntry(file.name) + zos.putNextEntry(ze) + var len: Int + while (inStream.read(buffer).also { len = it } > 0) { + zos.write(buffer, 0, len) + } + } + } + zos.finish() // Ensures the zip output is finalized + } + } + outputZipFile + } + } + private companion object { + const val BUFFER_SIZE = 1024 + const val LOG_FILE_NAME = "logs.txt" const val NEW_LOG_FILE_NAME = "app_logs.txt" + // the only name that we allow to send as email to company addresses + const val PERMITTED_FILE_NAME = "log.txt" + const val PERMITTED_FILE_NAME_ZIP = "log.zip" } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkAddressConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkAddressConverter.kt index e0c0192c84..5d3ba03610 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkAddressConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkAddressConverter.kt @@ -10,7 +10,7 @@ import timber.log.Timber * [REDACTED_AUTHOR] */ -internal class NetworkAddressConverter( +class NetworkAddressConverter( private val selectedAddress: String, ) : TwoWayConverter, NetworkAddress> { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkAmountsConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkAmountsConverter.kt index cea7a8a4b7..e3bac6232a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkAmountsConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkAmountsConverter.kt @@ -14,7 +14,7 @@ private typealias AmountsDomainModel = Map { +object NetworkAmountsConverter : TwoWayConverter { override fun convert(value: AmountsDataModel): AmountsDomainModel { return value diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkDerivationPathConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkDerivationPathConverter.kt index 92925a866f..1cffbc73f9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkDerivationPathConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkDerivationPathConverter.kt @@ -10,7 +10,7 @@ import com.tangem.utils.converter.TwoWayConverter * [REDACTED_AUTHOR] */ -internal object NetworkDerivationPathConverter : +object NetworkDerivationPathConverter : TwoWayConverter { override fun convert(value: NetworkStatusDM.DerivationPath): Network.DerivationPath { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkStatusDataModelConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkStatusDataModelConverter.kt index 34dc695aba..55ab2be060 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkStatusDataModelConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/converter/NetworkStatusDataModelConverter.kt @@ -9,7 +9,7 @@ import com.tangem.utils.converter.Converter * [REDACTED_AUTHOR] */ -internal object NetworkStatusDataModelConverter : Converter { +object NetworkStatusDataModelConverter : Converter { override fun convert(value: NetworkStatus): NetworkStatusDM? { return when (val status = value.value) { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt index 0ada82055f..40f26e9e08 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt @@ -6,7 +6,7 @@ import com.tangem.domain.tokens.model.Network import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel import java.math.BigDecimal -internal sealed interface NetworkStatusDM { +sealed interface NetworkStatusDM { val networkId: Network.ID val derivationPath: DerivationPath diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt index 43d82aaa8d..a418b31a5d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTPersistenceStore.kt @@ -12,7 +12,7 @@ internal class DefaultNFTPersistenceStore( private val pricesPersistenceStore: DataStore>, ) : NFTPersistenceStore { - override fun getCollections(): Flow> = collectionsPersistenceStore.data + override fun getCollections(): Flow?> = collectionsPersistenceStore.data override suspend fun getCollectionsSync(): List? = collectionsPersistenceStore .data diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt index 753597e44a..cf693aab39 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/DefaultNFTRuntimeStore.kt @@ -42,7 +42,7 @@ internal class DefaultNFTRuntimeStore( ) } - override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow = + override fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow = collectionsRuntimeStore .get() .combine(getSalePrice(assetId)) { collectionsData, price -> @@ -50,13 +50,17 @@ internal class DefaultNFTRuntimeStore( .getCollection(collectionId) ?.getAsset(assetId) ?.mergeWithPrice(price) - ?: NFTAsset.Error(assetId) } override fun getSalePrice(assetId: NFTAsset.Identifier): Flow = pricesRuntimeStore .get() .map { it[assetId] ?: NFTSalePrice.Empty(assetId) } + override suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice = pricesRuntimeStore + .getSyncOrNull() + ?.let { it[assetId] } + ?: NFTSalePrice.Empty(assetId) + override suspend fun saveCollections(collections: NFTCollections) { collectionsRuntimeStore.store(collections) } @@ -72,8 +76,13 @@ internal class DefaultNFTRuntimeStore( ?.collections ?.firstOrNull { it.id == collectionId } - private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier): NFTAsset? = - assets.firstOrNull { it.id == assetId } + private fun NFTCollection.getAsset(assetId: NFTAsset.Identifier): NFTAsset? = when (val assets = assets) { + is NFTCollection.Assets.Empty, + is NFTCollection.Assets.Loading, + is NFTCollection.Assets.Failed, + -> null + is NFTCollection.Assets.Value -> assets.items.firstOrNull { it.id == assetId } + } private fun NFTCollections.mergeWithPrices(prices: Map): NFTCollections = when (val content = this.content) { @@ -89,18 +98,23 @@ internal class DefaultNFTRuntimeStore( copy( collections = this.collections?.map { data -> data.copy( - assets = data.assets.map { asset -> - asset.mergeWithPrice(prices[asset.id] ?: NFTSalePrice.Empty(asset.id)) + assets = when (val assets = data.assets) { + is NFTCollection.Assets.Empty, + is NFTCollection.Assets.Loading, + is NFTCollection.Assets.Failed, + -> assets + is NFTCollection.Assets.Value -> assets.copy( + items = assets.items.map { asset -> + asset.mergeWithPrice(prices[asset.id] ?: NFTSalePrice.Empty(asset.id)) + }, + ) }, ) }, source = this.source, ) - private fun NFTAsset.mergeWithPrice(price: NFTSalePrice): NFTAsset = when (this) { - is NFTAsset.Error -> this - is NFTAsset.Value -> copy( - salePrice = price, - ) - } + private fun NFTAsset.mergeWithPrice(price: NFTSalePrice): NFTAsset = copy( + salePrice = price, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStore.kt index ecd7275a6a..3a6b27d9f3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStore.kt @@ -5,7 +5,7 @@ import com.tangem.blockchain.nft.models.NFTCollection import kotlinx.coroutines.flow.Flow interface NFTPersistenceStore { - fun getCollections(): Flow> + fun getCollections(): Flow?> suspend fun getCollectionsSync(): List? diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt index 49d0ce8415..cb2e6399bf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTPersistenceStoreFactory.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.local.nft import android.content.Context +import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi @@ -11,10 +12,12 @@ import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.listTypes import com.tangem.datasource.utils.mapWithCustomKeyTypes import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import java.lang.reflect.ParameterizedType import javax.inject.Inject import javax.inject.Singleton @@ -25,41 +28,51 @@ class NFTPersistenceStoreFactory @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, ) { - fun provide(network: Network): NFTPersistenceStore { - val networkStringIdentifier = listOfNotNull( - network.id.value, - network.derivationPath.value, - ).joinToString("_") { - // remove all non-alphanumeric characters - it - .toCharArray() - .filter(Char::isLetterOrDigit) - .joinToString("") - .lowercase() - } + fun provide(userWalletId: UserWalletId, network: Network): NFTPersistenceStore { + // simplify network identifier so that is correct for a file name + // e.g. eth_m4460000 or theopennetwork_m446070 + val networkStringId = + network.id.formatted() + network.derivationPath.formatted()?.let { "_$it" }.orEmpty() + // simplify user wallet id so that is correct for a file name + // e.g. 9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a + val userWalletStringId = userWalletId.formatted() return DefaultNFTPersistenceStore( - collectionsPersistenceStore = DataStoreFactory.create( - serializer = MoshiDataStoreSerializer( - moshi = moshi, - types = listTypes(), - defaultValue = emptyList(), - ), - produceFile = { - context.dataStoreFile(fileName = "nft_${networkStringIdentifier}_collections") - }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + collectionsPersistenceStore = createPersistenceStore( + // result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_eth_m4460000_collections + // result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_theopennetwork_m446070_collections + fileName = "nft_${userWalletStringId}_${networkStringId}_collections", + types = listTypes(), + defaultValue = emptyList(), ), - pricesPersistenceStore = DataStoreFactory.create( - serializer = MoshiDataStoreSerializer( - moshi = moshi, - types = mapWithCustomKeyTypes(), - defaultValue = emptyMap(), - ), - produceFile = { - context.dataStoreFile(fileName = "nft_${networkStringIdentifier}_prices") - }, - scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + pricesPersistenceStore = createPersistenceStore( + // result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_eth_m4460000_prices + // result file name example: nft_9a1a178f951a7115555568c09ebad8a882f3d96de25429f0017fe570931e208a_theopennetwork_m446070_prices + fileName = "nft_${userWalletStringId}_${networkStringId}_prices", + types = mapWithCustomKeyTypes(), + defaultValue = emptyMap(), ), ) } + + private fun createPersistenceStore(fileName: String, types: ParameterizedType, defaultValue: T): DataStore = + DataStoreFactory.create( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = types, + defaultValue = defaultValue, + ), + produceFile = { context.dataStoreFile(fileName = fileName) }, + scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), + ) + + private fun Network.ID.formatted(): String = value + .filter(Char::isLetterOrDigit) + .lowercase() + + private fun Network.DerivationPath.formatted(): String? = value + ?.filter(Char::isLetterOrDigit) + ?.lowercase() + + private fun UserWalletId.formatted(): String = stringValue + .lowercase() } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt index e9b27f5d0c..8c211d1f8e 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/NFTRuntimeStore.kt @@ -16,7 +16,9 @@ interface NFTRuntimeStore { fun getAsset(collectionId: NFTCollection.Identifier, assetId: NFTAsset.Identifier): Flow - fun getSalePrice(assetId: NFTAsset.Identifier): Flow + fun getSalePrice(assetId: NFTAsset.Identifier): Flow + + suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice suspend fun saveCollections(collections: NFTCollections) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt index ce67e88dd6..db95b8ce76 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetConverter.kt @@ -7,15 +7,12 @@ import com.tangem.domain.tokens.model.Network import com.tangem.utils.converter.Converter import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset -class NFTSdkAssetConverter( - private val nftSdkAssetIdentifierConverter: NFTSdkAssetIdentifierConverter, - private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter, -) : Converter, NFTAsset> { +object NFTSdkAssetConverter : Converter, NFTAsset> { override fun convert(value: Pair): NFTAsset { val (network, asset) = value - val assetId = nftSdkAssetIdentifierConverter.convert(asset.identifier) - val collectionId = nftSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier) - return NFTAsset.Value( + val assetId = NFTSdkAssetIdentifierConverter.convert(asset.identifier) + val collectionId = NFTSdkCollectionIdentifierConverter.convert(asset.collectionIdentifier) + return NFTAsset( id = assetId, collectionId = collectionId, network = network, @@ -28,23 +25,22 @@ class NFTSdkAssetConverter( assetId = assetId, value = it.value, symbol = it.symbol, - source = StatusSource.CACHE, ) } ?: NFTSalePrice.Empty(assetId = assetId), rarity = asset.rarity?.let { - NFTAsset.Value.Rarity( + NFTAsset.Rarity( rank = it.rank, label = it.label, ) }, media = asset.media?.let { - NFTAsset.Value.Media( + NFTAsset.Media( url = it.url, mimetype = it.mimetype, ) }, traits = asset.traits.map { - NFTAsset.Value.Trait( + NFTAsset.Trait( name = it.name, value = it.value, ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt index 7b1be28a4e..603e003ccb 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionConverter.kt @@ -1,18 +1,16 @@ package com.tangem.datasource.local.nft.converter +import com.tangem.domain.models.StatusSource import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.tokens.model.Network import com.tangem.utils.converter.Converter import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection -class NFTSdkCollectionConverter( - private val nftSdkCollectionIdentifierConverter: NFTSdkCollectionIdentifierConverter, - private val nftSdkAssetConverter: NFTSdkAssetConverter, -) : Converter, NFTCollection> { +object NFTSdkCollectionConverter : Converter, NFTCollection> { override fun convert(value: Pair): NFTCollection { val (network, collection) = value - val collectionId = nftSdkCollectionIdentifierConverter.convert(collection.identifier) + val collectionId = NFTSdkCollectionIdentifierConverter.convert(collection.identifier) return NFTCollection( id = collectionId, network = network, @@ -22,10 +20,20 @@ class NFTSdkCollectionConverter( count = collection.count, assets = collection.assets .map { asset -> - nftSdkAssetConverter.convert(network to asset) + NFTSdkAssetConverter.convert(network to asset) } .filter { it.id !is NFTAsset.Identifier.Unknown + } + .let { + if (it.isEmpty()) { + NFTCollection.Assets.Empty + } else { + NFTCollection.Assets.Value( + items = it, + source = StatusSource.CACHE, + ) + } }, ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingYieldsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingYieldsStore.kt index eb27aad46b..874e884f87 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingYieldsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultStakingYieldsStore.kt @@ -1,32 +1,25 @@ package com.tangem.datasource.local.token +import androidx.datastore.core.DataStore import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO -import com.tangem.datasource.local.datastore.core.StringKeyDataStore import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.flow.firstOrNull internal class DefaultStakingYieldsStore( - private val dataStore: StringKeyDataStore>, + private val dataStore: DataStore>, ) : StakingYieldsStore { - private val mutex = Mutex() - override fun get(): Flow> { - return dataStore.get(KEY) + return dataStore.data } override suspend fun getSync(): List { - return dataStore.getSyncOrNull(KEY) ?: emptyList() + return dataStore.data.firstOrNull().orEmpty() } override suspend fun store(items: List) { - mutex.withLock { - dataStore.store(KEY, items) + dataStore.updateData { _ -> + items } } - - companion object { - private const val KEY = "DefaultStakingYieldsStore" - } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/PendingActionConstraintsConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/PendingActionConstraintsConverter.kt new file mode 100644 index 0000000000..2284023080 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/converter/PendingActionConstraintsConverter.kt @@ -0,0 +1,20 @@ +package com.tangem.datasource.local.token.converter + +import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO +import com.tangem.domain.staking.model.stakekit.PendingAction +import com.tangem.domain.staking.model.stakekit.PendingActionConstraints +import com.tangem.utils.converter.Converter + +internal object PendingActionConstraintsConverter : + Converter { + override fun convert(value: BalanceDTO.PendingActionConstraints): PendingActionConstraints { + return PendingActionConstraints( + type = StakingActionTypeConverter.convert(value.type), + amountArg = PendingAction.PendingActionArgs.Amount( + required = value.amountArg.required, + minimum = value.amountArg.minimum, + maximum = value.amountArg.maximum, + ), + ) + } +} \ 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 6fc465801a..051e63780e 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 @@ -38,6 +38,8 @@ internal class YieldBalanceConverter( pendingActions = PendingActionConverter .convertList(item.pendingActions) .sortedBy { it.passthrough }, + pendingActionsConstraints = PendingActionConstraintsConverter + .convertList(item.pendingActionConstraints.orEmpty()), isPending = false, ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt index 54fe4ff322..56acd23a53 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/userwallet/UserWalletsStore.kt @@ -11,7 +11,9 @@ interface UserWalletsStore { val userWallets: Flow> - suspend fun getSyncOrNull(key: UserWalletId): UserWallet? + fun getSyncOrNull(key: UserWalletId): UserWallet? + + suspend fun getSyncStrict(key: UserWalletId): UserWallet suspend fun getAllSyncOrNull(): List? diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt new file mode 100644 index 0000000000..79da8620de --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/DefaultWalletConnectStore.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.local.walletconnect + +import androidx.datastore.core.DataStore +import com.tangem.domain.walletconnect.model.WcSessionDTO +import kotlinx.coroutines.flow.Flow + +internal typealias WcSessionCollection = Set + +internal class DefaultWalletConnectStore( + private val persistenceStore: DataStore, +) : WalletConnectStore { + + override val sessions: Flow + get() = persistenceStore.data + + override suspend fun saveSessions(sessions: WcSessionCollection) { + persistenceStore.updateData { data -> data.plus(sessions) } + } + + override suspend fun removeSessions(sessions: WcSessionCollection) { + persistenceStore.updateData { data -> data.minus(sessions) } + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt new file mode 100644 index 0000000000..4f3a8397b8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/walletconnect/WalletConnectStore.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.local.walletconnect + +import com.tangem.domain.walletconnect.model.WcSessionDTO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +interface WalletConnectStore { + + val sessions: Flow + suspend fun findSessionByTopic(topic: String) = sessions.first().find { it.topic == topic } + + suspend fun saveSessions(sessions: WcSessionCollection) + suspend fun saveSession(session: WcSessionDTO) = saveSessions(setOf(session)) + + suspend fun removeSessions(sessions: WcSessionCollection) + suspend fun removeSession(session: WcSessionDTO) = removeSessions(setOf(session)) +} \ No newline at end of file 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 a58ab9efa6..d4158d4965 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,8 +6,6 @@ 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.DEBUG_PG_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 @@ -91,18 +89,17 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { is StakeKit -> createStakeKitModel() is TangemVisaAuth -> createVisaAuthModel() is TangemVisa -> createVisaModel() + is TangemCardSdk -> createTangemCardSdkModel() } } private fun createExpressModel(): Model { val environment = when (BuildConfig.BUILD_TYPE) { DEBUG_BUILD_TYPE, - DEBUG_PG_BUILD_TYPE, -> ApiEnvironment.DEV INTERNAL_BUILD_TYPE, MOCKED_BUILD_TYPE, -> ApiEnvironment.STAGE - EXTERNAL_BUILD_TYPE, RELEASE_BUILD_TYPE, -> ApiEnvironment.PROD else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]") @@ -114,12 +111,10 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { environment = environment, baseUrl = when (BuildConfig.BUILD_TYPE) { DEBUG_BUILD_TYPE, - DEBUG_PG_BUILD_TYPE, -> "[REDACTED_ENV_URL]" 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}]") @@ -152,7 +147,7 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { id = ApiConfig.ID.TangemTech, expected = ApiEnvironmentConfig( environment = ApiEnvironment.PROD, - baseUrl = "https://api.tangem-tech.com/v1/", + baseUrl = "https://api.tangem.org/v1/", headers = mapOf( "card_id" to ProviderSuspend { APP_CARD_ID }, "card_public_key" to ProviderSuspend { APP_CARD_PUBLIC_KEY }, @@ -210,6 +205,16 @@ internal class ProdApiConfigsManagerTest(private val model: Model) { ) } + private fun createTangemCardSdkModel(): Model { + return Model( + id = ApiConfig.ID.TangemCardSdk, + expected = ApiEnvironmentConfig( + environment = ApiEnvironment.PROD, + baseUrl = "https://api.tangem-tech.com/", + ), + ) + } + private fun String.checkHeaderValueOrEmpty(): String { for (i in this.indices) { val c = this[i] diff --git a/core/error/.gitignore b/core/error/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/core/error/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/error/build.gradle.kts b/core/error/build.gradle.kts new file mode 100644 index 0000000000..7ff7fb7522 --- /dev/null +++ b/core/error/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} \ No newline at end of file diff --git a/core/error/ext/.gitignore b/core/error/ext/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/core/error/ext/.gitignore @@ -0,0 +1 @@ +/build diff --git a/core/error/ext/build.gradle.kts b/core/error/ext/build.gradle.kts new file mode 100644 index 0000000000..f6e50d5bd6 --- /dev/null +++ b/core/error/ext/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.core.error.ext" +} + +dependencies { + api(projects.core.error) + + implementation(tangemDeps.card.core) + implementation(tangemDeps.blockchain) +} \ No newline at end of file diff --git a/core/error/ext/src/main/kotlin/com/tangem/core/error/ext/Extensions.kt b/core/error/ext/src/main/kotlin/com/tangem/core/error/ext/Extensions.kt new file mode 100644 index 0000000000..587b53931c --- /dev/null +++ b/core/error/ext/src/main/kotlin/com/tangem/core/error/ext/Extensions.kt @@ -0,0 +1,27 @@ +package com.tangem.core.error.ext + +import com.tangem.common.core.TangemSdkError +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.common.core.TangemError +import com.tangem.core.error.UniversalError + +val TangemError.universalError: UniversalError + get() = when (this) { + is TangemSdkError -> object : UniversalError { + override val errorCode: Int = 101000000 + code + } + else -> object : UniversalError { + override val errorCode: Int = code + } + } + +val BlockchainSdkError.universalError: UniversalError + get() = object : UniversalError { + override val errorCode: Int = 102000000 + code + } + +val UniversalError.tangemError: TangemError + get() = object : TangemError(errorCode) { + override var customMessage: String = errorCode.toString() + override val messageResId: Int? = null + } \ No newline at end of file diff --git a/core/error/src/main/kotlin/com/tangem/core/error/UniversalError.kt b/core/error/src/main/kotlin/com/tangem/core/error/UniversalError.kt new file mode 100644 index 0000000000..dd7a15e9c2 --- /dev/null +++ b/core/error/src/main/kotlin/com/tangem/core/error/UniversalError.kt @@ -0,0 +1,23 @@ +package com.tangem.core.error + +/** + * Universal error interface for all errors in the app. + * Each error code must follow this format: xxxyyyzzz where + * xxx - Feature code + * yyy - Subsystem code + * zzz - Specific error code + * If you need to add new feature add it to list below incrementing last code. + * + * Features: + * `100` - App error + * `101` - TangemSdkError + * `102` - BlockchainSdkError + * `103` - Express + * `104` - Visa + * `105` - Staking + * `106` - NFT + * `107` - WalletConnect + */ +interface UniversalError { + val errorCode: Int +} \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index c64eaaec68..a23e1b705f 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -116,6 +116,7 @@ Create %1$s (%2$s) Custom + NFT %d day %d days @@ -178,6 +179,7 @@ Search Search tokens sec + See all Seed phrase Select action Sell @@ -510,10 +512,24 @@ NFT collections Some data may not load Temporary loading problems + No results. Please try another request. + Receive NFT + To My wallet %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 Set up a single access code to protect all your devices. Protect Set an individual access code for each card or ring later. @@ -645,6 +661,7 @@ Camera access denied No memo required %1$s (%2$s) on %3$s network + %1$s on %2$s network Sending any other currency will result in its irreversible loss. Send only %s to this address. Sending any other currency will result in its irreversible loss. Send only %1$s on the %2$s network diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 0e9ea7edd7..78fc52949f 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -6,6 +6,13 @@ plugins { android { namespace = "com.tangem.core.ui" + + packaging { + resources { + // To build and run composable preview + merges += "paymentrequest.proto" + } + } } dependencies { @@ -17,6 +24,7 @@ dependencies { implementation(projects.core.res) implementation(projects.core.utils) implementation(projects.core.decompose) + implementation(projects.core.error) /** AndroidX libraries */ implementation(deps.androidx.fragment.ktx) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Spacers.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Spacers.kt index 99230c1853..131ca91e00 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Spacers.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Spacers.kt @@ -14,6 +14,11 @@ fun SpacerH(height: Dp, modifier: Modifier = Modifier) { Spacer(modifier = modifier.height(height)) } +@Composable +fun SpacerH2(modifier: Modifier = Modifier) { + SpacerH(2.dp, modifier) +} + @Composable fun SpacerH4(modifier: Modifier = Modifier) { SpacerH(4.dp, modifier) diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt b/core/ui/src/main/java/com/tangem/core/ui/components/UnableToLoadData.kt similarity index 88% rename from features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/UnableToLoadData.kt index c96d848bac..19f8e5fcbd 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/UnableToLoadData.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/UnableToLoadData.kt @@ -1,4 +1,4 @@ -package com.tangem.features.markets.tokenlist.impl.ui.components +package com.tangem.core.ui.components import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement @@ -8,16 +8,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.markets.impl.R @Composable -internal fun UnableToLoadData(onRetryClick: () -> Unit, modifier: Modifier = Modifier) { +fun UnableToLoadData(onRetryClick: () -> Unit, modifier: Modifier = Modifier) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt index 3c5b7a606d..e5c28bf0a6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/information/InformationBlock.kt @@ -30,7 +30,7 @@ class InformationBlockContentScope(val scope: BoxScope) : BoxScope by scope @Composable fun InformationBlock( - title: @Composable BoxScope.() -> Unit, + title: (@Composable BoxScope.() -> Unit)?, modifier: Modifier = Modifier, contentHorizontalPadding: Dp = TangemTheme.dimens.spacing12, shape: Shape = TangemTheme.shapes.roundedCornersXMedium, @@ -43,7 +43,9 @@ fun InformationBlock( .background(color = TangemTheme.colors.background.action), horizontalAlignment = Alignment.Start, ) { - NetworkTitle(title = title, action = action) + if (title != null) { + NetworkTitle(title = title, action = action) + } if (content != null) { Box( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/chooseaddress/ChooseAddressBottomSheetConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/chooseaddress/ChooseAddressBottomSheetConfig.kt deleted file mode 100644 index 2bbbe6c79e..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/chooseaddress/ChooseAddressBottomSheetConfig.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.core.ui.components.bottomsheets.chooseaddress - -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel -import kotlinx.collections.immutable.ImmutableList - -class ChooseAddressBottomSheetConfig( - val addressModels: ImmutableList, - val onClick: (AddressModel) -> Unit, -) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt new file mode 100644 index 0000000000..b4df773674 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt @@ -0,0 +1,88 @@ +package com.tangem.core.ui.components.bottomsheets.internal + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.key.* +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModalBottomSheetWithBackHandling( + onDismissRequest: () -> Unit, + onBack: (() -> Unit), + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(), + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = BottomSheetDefaults.ContainerColor, + contentColor: Color = contentColorFor(containerColor), + tonalElevation: Dp = 0.dp, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, + content: @Composable ColumnScope.() -> Unit, +) { + BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) { + onBack() + } + + val requester = remember { FocusRequester() } + val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier + .focusRequester(requester) + .focusable() + .onPreviewKeyEvent { + if (it.key == Key.Back && it.type == KeyEventType.KeyUp && !it.nativeKeyEvent.isCanceled) { + backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed() + return@onPreviewKeyEvent true + } + return@onPreviewKeyEvent false + }, + sheetState = sheetState, + sheetMaxWidth = sheetMaxWidth, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + scrimColor = scrimColor, + dragHandle = dragHandle, + contentWindowInsets = contentWindowInsets, + properties = ModalBottomSheetDefaults.properties( + // Set false otherwise the onPreviewKeyEvent doesn't work at all. + // The functionality of shouldDismissOnBackPress is achieved by the BackHandler. + shouldDismissOnBackPress = false, + ), + content = content, + ) + + LaunchedEffect(Unit) { + delay(timeMillis = 200) + requester.requestFocus() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +suspend fun SheetState.collapse(onCollapsed: () -> Unit) { + coroutineScope { + launch { hide() }.invokeOnCompletion { onCollapsed() } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt index f0f478d567..9ecae2ab38 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt @@ -16,7 +16,7 @@ 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.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.isNullOrEmpty import com.tangem.core.ui.extensions.resolveReference diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt new file mode 100644 index 0000000000..f97a4f5daf --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -0,0 +1,262 @@ +package com.tangem.core.ui.components.bottomsheets.modal + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.material3.SheetValue.Expanded +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.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +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.SpacerH +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.internal.ModalBottomSheetWithBackHandling +import com.tangem.core.ui.components.bottomsheets.internal.collapse +import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.WindowInsetsZero + +const val MODAL_SHEET_MAX_HEIGHT = 0.8f + +/** + * Modal bottom sheet with [content] and optional [title]. + * + * Maximum height of sheet is 80% screen height + * + * [Show in Figma](https://www.figma.com/design/09KKG4ZVuFDZhj8WLv5rGJ/%F0%9F%9A%A7-App-experience?node-id=3254-61208&t=vixa7id6ggALcxfF-4) + */ +@Composable +inline fun TangemModalBottomSheet( + config: TangemBottomSheetConfig, + containerColor: Color = TangemTheme.colors.background.primary, + skipPartiallyExpanded: Boolean = true, + noinline onBack: (() -> Unit)? = null, + crossinline title: @Composable BoxScope.(T) -> Unit = {}, + crossinline content: @Composable ColumnScope.(T) -> Unit, +) { + val isAlwaysVisible = LocalBottomSheetAlwaysVisible.current + + if (isAlwaysVisible) { + PreviewModalBottomSheet( + config = config, + containerColor = containerColor, + title = title, + content = content, + skipPartiallyExpanded = skipPartiallyExpanded, + ) + } else { + DefaultModalBottomSheet( + config = config, + containerColor = containerColor, + title = title, + content = content, + onBack = onBack, + skipPartiallyExpanded = skipPartiallyExpanded, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +inline fun DefaultModalBottomSheet( + config: TangemBottomSheetConfig, + containerColor: Color, + skipPartiallyExpanded: Boolean = true, + noinline onBack: (() -> Unit)? = null, + crossinline title: @Composable (BoxScope.(T) -> Unit), + crossinline content: @Composable (ColumnScope.(T) -> Unit), +) { + var isVisible by remember { mutableStateOf(value = config.isShown) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) + + if (isVisible && config.content is T) { + BasicModalBottomSheet( + config = config, + sheetState = sheetState, + containerColor = containerColor, + title = title, + onBack = onBack, + content = content, + ) + } + + LaunchedEffect(key1 = config.isShown) { + if (config.isShown) { + isVisible = true + } else { + sheetState.collapse { isVisible = false } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +inline fun PreviewModalBottomSheet( + config: TangemBottomSheetConfig, + containerColor: Color, + skipPartiallyExpanded: Boolean = true, + crossinline title: @Composable (BoxScope.(T) -> Unit), + crossinline content: @Composable (ColumnScope.(T) -> Unit), +) { + BasicModalBottomSheet( + config = config, + sheetState = SheetState( + skipPartiallyExpanded = skipPartiallyExpanded, + initialValue = Expanded, + density = LocalDensity.current, + ), + onBack = null, + containerColor = containerColor, + title = title, + content = content, + ) +} + +@Suppress("LongParameterList") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +inline fun BasicModalBottomSheet( + config: TangemBottomSheetConfig, + sheetState: SheetState, + containerColor: Color, + noinline onBack: (() -> Unit)? = null, + crossinline title: @Composable (BoxScope.(T) -> Unit), + crossinline content: @Composable (ColumnScope.(T) -> Unit), + modifier: Modifier = Modifier, +) { + val model = config.content as? T ?: return + + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + + val bsContent: @Composable ColumnScope.() -> Unit = { + val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT + + Column( + modifier = Modifier + .systemBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp) + .clip(TangemTheme.shapes.roundedCornersLarge) + .background(containerColor) + .heightIn(max = maxHeight.dp) + .padding(bottom = bottomBarHeight) + .fillMaxWidth(), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + title(model) + } + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + content(model) + } + } + } + + if (onBack != null) { + ModalBottomSheetWithBackHandling( + modifier = modifier, + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = Color.Transparent, + shape = TangemTheme.shapes.roundedCornersLarge, + contentWindowInsets = { WindowInsetsZero }, + onBack = onBack, + dragHandle = null, + content = bsContent, + ) + } else { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = config.onDismissRequest, + sheetState = sheetState, + containerColor = Color.Transparent, + shape = TangemTheme.shapes.roundedCornersLarge, + contentWindowInsets = { WindowInsetsZero }, + dragHandle = null, + content = bsContent, + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360, heightDp = 800) +@Preview(showBackground = true, widthDp = 360, heightDp = 800, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemModalBottomSheet_Preview() { + TangemThemePreview { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = TangemBottomSheetConfigContentPreviewConfig(), + ), + title = { + TangemModalBottomSheetTitle( + endIconRes = R.drawable.ic_close_24, + onEndClick = {}, + ) + }, + content = { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(100)) + .background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f)) + .padding(12.dp), + painter = rememberVectorPainter( + ImageVector.vectorResource(R.drawable.ic_alert_24), + ), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + SpacerH24() + Text( + text = "Unsuported networks", + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + text = "Tangem does not currently support a required network by React App.", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH(48.dp) + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = "Go it", + onClick = {}, + ) + } + }, + ) + } +} + +private class TangemBottomSheetConfigContentPreviewConfig : TangemBottomSheetConfigContent +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt new file mode 100644 index 0000000000..7029f1c2e0 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetTitle.kt @@ -0,0 +1,131 @@ +package com.tangem.core.ui.components.bottomsheets.modal + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +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.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.buttons.small.TangemIconButton +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.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +/** + * Title component for [TangemModalBottomSheet] with [TangemIconButton] for buttons. + */ +@Composable +fun TangemModalBottomSheetTitle( + modifier: Modifier = Modifier, + title: TextReference? = null, + @DrawableRes startIconRes: Int? = null, + onStartClick: (() -> Unit)? = null, + @DrawableRes endIconRes: Int? = null, + onEndClick: (() -> Unit)? = null, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(56.dp), + ) { + if (startIconRes != null && onStartClick != null) { + TangemIconButton( + iconRes = startIconRes, + onClick = onStartClick, + modifier = Modifier + .padding(16.dp) + .align(Alignment.CenterStart), + ) + } + if (title != null) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.align(Alignment.Center), + ) + } + if (endIconRes != null && onEndClick != null) { + TangemIconButton( + iconRes = endIconRes, + onClick = onEndClick, + modifier = Modifier + .padding(16.dp) + .align(Alignment.CenterEnd), + ) + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_TangemModalBottomSheetTitle( + @PreviewParameter(TangemModalBottomSheetTitleProvider::class) params: TangemModalBottomSheetTitleData, +) { + TangemThemePreview { + TangemModalBottomSheetTitle( + title = params.title, + startIconRes = params.startIconRes, + onStartClick = params.onStartClick, + endIconRes = params.endIconRes, + onEndClick = params.onEndClick, + modifier = Modifier.background(TangemTheme.colors.background.secondary), + ) + } +} + +private data class TangemModalBottomSheetTitleData( + val title: TextReference?, + val startIconRes: Int?, + val onStartClick: (() -> Unit)?, + val endIconRes: Int?, + val onEndClick: (() -> Unit)?, +) + +private class TangemModalBottomSheetTitleProvider : PreviewParameterProvider { + override val values = sequenceOf( + TangemModalBottomSheetTitleData( + title = resourceReference(R.string.wallet_title), + startIconRes = R.drawable.ic_back_24, + onStartClick = {}, + endIconRes = null, + onEndClick = null, + ), + TangemModalBottomSheetTitleData( + title = resourceReference(R.string.wallet_title), + startIconRes = null, + onStartClick = null, + endIconRes = R.drawable.ic_close_24, + onEndClick = {}, + ), + TangemModalBottomSheetTitleData( + title = resourceReference(R.string.wallet_title), + startIconRes = R.drawable.ic_back_24, + onStartClick = {}, + endIconRes = R.drawable.ic_close_24, + onEndClick = {}, + ), + TangemModalBottomSheetTitleData( + title = null, + startIconRes = R.drawable.ic_back_24, + onStartClick = {}, + endIconRes = R.drawable.ic_close_24, + onEndClick = {}, + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt similarity index 68% rename from core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt index c433f996ea..177cd551ad 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt @@ -1,29 +1,25 @@ -package com.tangem.core.ui.components.bottomsheets +package com.tangem.core.ui.components.bottomsheets.sheet -import androidx.activity.compose.BackHandler -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue.Expanded +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.internal.ModalBottomSheetWithBackHandling +import com.tangem.core.ui.components.bottomsheets.internal.collapse import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.utils.WindowInsetsZero -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch /** * Bottom sheet with [content], [titleText] and optional [titleAction]. @@ -209,70 +205,4 @@ inline fun BasicBottomSheet( content = bsContent, ) } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ModalBottomSheetWithBackHandling( - onDismissRequest: () -> Unit, - onBack: (() -> Unit), - modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(), - sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, - shape: Shape = BottomSheetDefaults.ExpandedShape, - containerColor: Color = BottomSheetDefaults.ContainerColor, - contentColor: Color = contentColorFor(containerColor), - tonalElevation: Dp = 0.dp, - scrimColor: Color = BottomSheetDefaults.ScrimColor, - dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, - contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, - content: @Composable ColumnScope.() -> Unit, -) { - BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) { - onBack() - } - - val requester = remember { FocusRequester() } - val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current - - ModalBottomSheet( - onDismissRequest = onDismissRequest, - modifier = modifier - .focusRequester(requester) - .focusable() - .onPreviewKeyEvent { - if (it.key == Key.Back && it.type == KeyEventType.KeyUp && !it.nativeKeyEvent.isCanceled) { - backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed() - return@onPreviewKeyEvent true - } - return@onPreviewKeyEvent false - }, - sheetState = sheetState, - sheetMaxWidth = sheetMaxWidth, - shape = shape, - containerColor = containerColor, - contentColor = contentColor, - tonalElevation = tonalElevation, - scrimColor = scrimColor, - dragHandle = dragHandle, - contentWindowInsets = contentWindowInsets, - properties = ModalBottomSheetDefaults.properties( - // Set false otherwise the onPreviewKeyEvent doesn't work at all. - // The functionality of shouldDismissOnBackPress is achieved by the BackHandler. - shouldDismissOnBackPress = false, - ), - content = content, - ) - - LaunchedEffect(Unit) { - delay(timeMillis = 200) - requester.requestFocus() - } -} - -@OptIn(ExperimentalMaterial3Api::class) -suspend fun SheetState.collapse(onCollapsed: () -> Unit) { - coroutineScope { - launch { hide() }.invokeOnCompletion { onCollapsed() } - } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetDraggableHeader.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeader.kt similarity index 95% rename from core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetDraggableHeader.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeader.kt index 37bff1013a..66b0ae6033 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetDraggableHeader.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetDraggableHeader.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.components.bottomsheets +package com.tangem.core.ui.components.bottomsheets.sheet import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetTitle.kt similarity index 97% rename from core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetTitle.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetTitle.kt index 65e7c87924..8ca60f6218 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheetTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheetTitle.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.components.bottomsheets +package com.tangem.core.ui.components.bottomsheets.sheet import android.content.res.Configuration import androidx.compose.runtime.Composable diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/AddressMappers.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/AddressMappers.kt deleted file mode 100644 index dfd23f44c2..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/AddressMappers.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.core.ui.components.bottomsheets.tokenreceive - -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.tokens.model.NetworkAddress - -private const val DSC_ADDRESS_NAME = "DSC" -private const val DEL_ADDRESS_NAME = "Main" - -private const val LEGACY_ADDRESS_NAME = "Legacy" -private const val DEFAULT_ADDRESS_NAME = "Default" - -fun Set.mapToAddressModels(currency: CryptoCurrency): List = this - .sortedBy { it.type.ordinal } - .map { address -> - AddressModel( - displayName = currency.network.getAddressDisplayName(address.type), - value = address.value, - type = when (address.type) { - NetworkAddress.Address.Type.Primary -> AddressModel.Type.Default - NetworkAddress.Address.Type.Secondary -> AddressModel.Type.Legacy - }, - ) - } - -private fun Network.getAddressDisplayName(addressType: NetworkAddress.Address.Type): TextReference { - return when (id.value) { - "decimal", "decimal/test" -> { - when (addressType) { - NetworkAddress.Address.Type.Primary -> stringReference(value = DEL_ADDRESS_NAME) - NetworkAddress.Address.Type.Secondary -> stringReference(value = DSC_ADDRESS_NAME) - } - } - else -> { - when (addressType) { - NetworkAddress.Address.Type.Primary -> stringReference(value = DEFAULT_ADDRESS_NAME) - NetworkAddress.Address.Type.Secondary -> stringReference(value = LEGACY_ADDRESS_NAME) - } - } - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/TokenReceiveBottomSheetConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/TokenReceiveBottomSheetConfig.kt deleted file mode 100644 index eee9fdb3bd..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/tokenreceive/TokenReceiveBottomSheetConfig.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.core.ui.components.bottomsheets.tokenreceive - -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import kotlinx.collections.immutable.ImmutableList - -data class TokenReceiveBottomSheetConfig( - val name: String, - val symbol: String, - val network: String, - val addresses: ImmutableList, - val showMemoDisclaimer: Boolean, - val onCopyClick: (String) -> Unit, - val onShareClick: (String) -> Unit, -) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/small/TangemIconButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/small/TangemIconButton.kt new file mode 100644 index 0000000000..39a042f707 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/small/TangemIconButton.kt @@ -0,0 +1,72 @@ +package com.tangem.core.ui.components.buttons.small + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +/** + * Small button icon + * + * @param iconRes icon resource + * @param onClick lambda be invoked when action component is clicked + * @param modifier composable modifier + * @param shape icon button shape + * @param background background color + * @param iconTint icon color + * + * [Show in Figma](https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=4105-1439&t=nnYBX1qCZmUNhBDf-4) + */ +@Composable +fun TangemIconButton( + @DrawableRes iconRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(24.dp), + background: Color = TangemTheme.colors.button.secondary, + iconTint: Color = TangemTheme.colors.icon.secondary, +) { + IconButton( + onClick = onClick, + modifier = modifier + .clip(shape) + .background(background) + .size(24.dp), + ) { + Icon( + painter = rememberVectorPainter(ImageVector.vectorResource(iconRes)), + contentDescription = "", + tint = iconTint, + modifier = Modifier.size(16.dp), + ) + } +} + +// region Preview +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemIconButton_Preview() { + TangemThemePreview { + TangemIconButton( + iconRes = R.drawable.ic_close_24, + onClick = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index a9a7677136..4727ba9759 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -109,7 +109,7 @@ private fun BoxScope.ContentIconContainer( ) if (icon.topBadgeIconResId != null && shouldShowTopBadge) { - TopBadge( + CurrencyIconTopBadge( modifier = Modifier .offset(x = networkBadgeOffset, y = -networkBadgeOffset) .align(Alignment.TopEnd), @@ -120,7 +120,7 @@ private fun BoxScope.ContentIconContainer( } if (icon.showCustomBadge) { - BottomBadge( + CurrencyIconBottomBadge( modifier = Modifier.align(Alignment.BottomEnd), ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconIconBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconIconBadge.kt new file mode 100644 index 0000000000..b074076629 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconIconBadge.kt @@ -0,0 +1,32 @@ +package com.tangem.core.ui.components.currency.icon + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun CurrencyIconBottomBadge(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(TangemTheme.dimens.size12) + .background( + color = TangemTheme.colors.background.primary, + shape = CircleShape, + ), + ) { + Box( + modifier = Modifier + .padding(all = TangemTheme.dimens.spacing2) + .matchParentSize() + .background( + color = TangemTheme.colors.icon.informative, + shape = CircleShape, + ), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/IconBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt similarity index 66% rename from core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/IconBadge.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt index 09e597f7a6..03057d495d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/IconBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconTopBadge.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.res.painterResource import com.tangem.core.ui.res.TangemTheme @Composable -internal fun TopBadge( +fun CurrencyIconTopBadge( @DrawableRes iconResId: Int, alpha: Float, colorFilter: ColorFilter?, @@ -38,26 +38,4 @@ internal fun TopBadge( contentDescription = null, ) } -} - -@Composable -internal fun BottomBadge(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(TangemTheme.dimens.size12) - .background( - color = TangemTheme.colors.background.primary, - shape = CircleShape, - ), - ) { - Box( - modifier = Modifier - .padding(all = TangemTheme.dimens.spacing2) - .matchParentSize() - .background( - color = TangemTheme.colors.icon.informative, - shape = CircleShape, - ), - ) - } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownItem.kt new file mode 100644 index 0000000000..b29403e17d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownItem.kt @@ -0,0 +1,47 @@ +package com.tangem.core.ui.components.dropdownmenu + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +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.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Suppress("ComposableEventParameterNaming") +@Composable +fun TangemDropdownItem(item: TangemDropdownMenuItem, dismissParent: () -> Unit, modifier: Modifier = Modifier) { + Text( + modifier = modifier + .clickable { + dismissParent() + item.onClick() + } + .padding(vertical = TangemTheme.dimens.spacing8, horizontal = TangemTheme.dimens.spacing16), + text = item.title.resolveReference(), + style = TangemTheme.typography.button.copy(color = item.textColorProvider()), + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_TokenDetailsAppBarDropdownItem() { + TangemThemePreview { + TangemDropdownItem( + modifier = Modifier.background(TangemTheme.colors.background.primary), + dismissParent = {}, + item = TangemDropdownMenuItem( + title = TextReference.Res(id = R.string.token_details_hide_token), + textColorProvider = { TangemTheme.colors.text.warning }, + onClick = { }, + ), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/DropdownMenu.kt b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenu.kt similarity index 90% rename from features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/DropdownMenu.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenu.kt index fa6ff8aca3..6974bc2d7a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/DropdownMenu.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenu.kt @@ -1,11 +1,17 @@ @file:Suppress("TopLevelPropertyNaming") -package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components + +package com.tangem.core.ui.components.dropdownmenu import androidx.compose.animation.core.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.TransformOrigin @@ -15,16 +21,19 @@ import androidx.compose.ui.unit.* import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties +import com.tangem.core.ui.res.TangemTheme +import kotlin.math.max +import kotlin.math.min /** * Just copy paste [DropdownMenu] from material3 with deleting vertical paddings. */ @Composable -internal fun TangemDropdownMenu( +fun TangemDropdownMenu( expanded: Boolean, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 0.dp), + offset: DpOffset = DpOffset(x = TangemTheme.dimens.spacing20, y = TangemTheme.dimens.spacing10.times(-1)), properties: PopupProperties = PopupProperties(focusable = true), content: @Composable ColumnScope.() -> Unit, ) { @@ -142,10 +151,7 @@ private fun calculateTransformOrigin(parentBounds: IntRect, menuBounds: IntRect) menuBounds.width == 0 -> 0f else -> { val intersectionCenter = - ( - kotlin.math.max(parentBounds.left, menuBounds.left) + - kotlin.math.min(parentBounds.right, menuBounds.right) - ) / 2 + (max(parentBounds.left, menuBounds.left) + min(parentBounds.right, menuBounds.right)) / 2 (intersectionCenter - menuBounds.left).toFloat() / menuBounds.width } } @@ -155,10 +161,7 @@ private fun calculateTransformOrigin(parentBounds: IntRect, menuBounds: IntRect) menuBounds.height == 0 -> 0f else -> { val intersectionCenter = - ( - kotlin.math.max(parentBounds.top, menuBounds.top) + - kotlin.math.min(parentBounds.bottom, menuBounds.bottom) - ) / 2 + (max(parentBounds.top, menuBounds.top) + min(parentBounds.bottom, menuBounds.bottom)) / 2 (intersectionCenter - menuBounds.top).toFloat() / menuBounds.height } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenuItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenuItem.kt new file mode 100644 index 0000000000..7d239636dd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/dropdownmenu/TangemDropdownMenuItem.kt @@ -0,0 +1,11 @@ +package com.tangem.core.ui.components.dropdownmenu + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.extensions.TextReference + +data class TangemDropdownMenuItem( + val title: TextReference, + val textColorProvider: @Composable () -> Color, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/InputManager.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/InputManager.kt similarity index 88% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/InputManager.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/fields/InputManager.kt index b3add7d7ee..42e959887b 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/utils/InputManager.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/InputManager.kt @@ -1,4 +1,4 @@ -package com.tangem.features.onramp.utils +package com.tangem.core.ui.components.fields import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.withDebounce @@ -12,7 +12,7 @@ import javax.inject.Inject * [REDACTED_AUTHOR] */ -internal class InputManager @Inject constructor() { +class InputManager @Inject constructor() { val query: Flow get() = _query diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt index 9863c30ba0..d69616e5d6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/SearchBar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager @@ -36,7 +37,11 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @Composable -fun SearchBar(state: SearchBarUM, modifier: Modifier = Modifier, colors: TextFieldColors = TangemSearchBarColors) { +fun SearchBar( + state: SearchBarUM, + modifier: Modifier = Modifier, + colors: TextFieldColors = TangemSearchBarDefaults.defaultTextFieldColors, +) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val interactionSource = remember { MutableInteractionSource() } @@ -161,18 +166,30 @@ private fun ClearButton( } } -private val TangemSearchBarColors: TextFieldColors - @Composable - get() = TextFieldDefaults.colors().copy( - focusedContainerColor = TangemTheme.colors.field.primary, - unfocusedContainerColor = TangemTheme.colors.field.primary, - focusedTextColor = TangemTheme.colors.text.primary1, - unfocusedTextColor = TangemTheme.colors.text.primary1, - cursorColor = TangemTheme.colors.icon.primary1, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ) +object TangemSearchBarDefaults { + val defaultTextFieldColors: TextFieldColors + @Composable @Stable get() = TextFieldDefaults.colors().copy( + focusedContainerColor = TangemTheme.colors.field.primary, + unfocusedContainerColor = TangemTheme.colors.field.primary, + focusedTextColor = TangemTheme.colors.text.primary1, + unfocusedTextColor = TangemTheme.colors.text.primary1, + cursorColor = TangemTheme.colors.icon.primary1, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ) + val secondaryTextFieldColors: TextFieldColors + @Composable @Stable get() = TextFieldDefaults.colors().copy( + focusedContainerColor = TangemTheme.colors.field.focused, + unfocusedContainerColor = TangemTheme.colors.field.focused, + focusedTextColor = TangemTheme.colors.text.primary1, + unfocusedTextColor = TangemTheme.colors.text.primary1, + cursorColor = TangemTheme.colors.icon.primary1, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ) +} // region Previews @Preview(widthDp = 328) diff --git a/core/ui/src/main/java/com/tangem/core/ui/decorations/RoundedDecorations.kt b/core/ui/src/main/java/com/tangem/core/ui/decorations/RoundedDecorations.kt index 02615d9faa..9b16d77616 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/decorations/RoundedDecorations.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/decorations/RoundedDecorations.kt @@ -32,7 +32,7 @@ fun Modifier.roundedShapeItemDecoration( val applyShape: Modifier.(shape: RoundedCornerShape?) -> Modifier = { shape -> if (backgroundColor != null) { if (shape != null) { - background(color = backgroundColor, shape = shape) + clip(shape).background(color = backgroundColor, shape = shape) } else { background(color = backgroundColor) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index 44c9a4dd6d..eb7f85d306 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -93,6 +93,7 @@ fun getActiveIconRes(blockchainId: String): Int { "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration "zklink", "zklink/test" -> R.drawable.img_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 + "pepecoin", "pepecoin/test" -> R.drawable.img_pepecoin_22 else -> R.drawable.ic_alert_24 } } @@ -184,6 +185,7 @@ fun getActiveIconResByCoinId(coinId: String): Int { "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration "zklink", "zklink/test" -> R.drawable.img_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 + "pepecoin-network", "pepecoin-network/test" -> R.drawable.img_pepecoin_22 else -> R.drawable.ic_alert_24 } } @@ -278,6 +280,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "scroll", "scroll/test" -> R.drawable.ic_alert_24 // FIXME: add icon during full integration "zklink", "zklink/test" -> R.drawable.ic_zklink_22 "vanar-chain", "vanar-chain/test" -> R.drawable.ic_vanar_22 + "pepecoin", "pepecoin/test" -> R.drawable.ic_pepecoin_22 else -> R.drawable.ic_alert_24 } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/message/dialog/Dialogs.kt b/core/ui/src/main/java/com/tangem/core/ui/message/dialog/Dialogs.kt index 86aa88795f..b8cac08019 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/message/dialog/Dialogs.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/message/dialog/Dialogs.kt @@ -1,8 +1,10 @@ package com.tangem.core.ui.message.dialog +import com.tangem.core.error.UniversalError import com.tangem.core.ui.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction @@ -36,4 +38,17 @@ object Dialogs { secondActionBuilder = { cancelAction(onClick = onCancelClick) }, ) } + + /** + * Universal error dialog + */ + fun universalErrorDialog(universalError: UniversalError, onOkClick: () -> Unit): DialogMessage { + return DialogMessage( + message = resourceReference(R.string.universal_error, wrappedList(universalError.errorCode)), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_ok), + onClick = onOkClick, + ), + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt index 28d531895d..b1ca7da16a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemAnimations.kt @@ -22,9 +22,9 @@ object TangemAnimations { forwardWhen: (initial: S, target: S) -> Boolean, ): AnimatedContentTransitionScope.() -> ContentTransform = { val direction = if (forwardWhen(initialState, targetState)) { - AnimatedContentTransitionScope.SlideDirection.End - } else { AnimatedContentTransitionScope.SlideDirection.Start + } else { + AnimatedContentTransitionScope.SlideDirection.End } slideIntoContainer(towards = direction, animationSpec = tween()) diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index de31c28cc4..fd2a88637f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -312,6 +312,11 @@ val LocalBladeAnimation = staticCompositionLocalOf { error("No MainBottomSheetColor provided") } +// TODO remove this after migration to new navigation +val LocalIsNavigationRefactoringEnabled = staticCompositionLocalOf { + false +} + /** * Determines whether the dark theme should be used based on the given [AppThemeMode]. * diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/UiMessageSenderUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/UiMessageSenderUtils.kt new file mode 100644 index 0000000000..d237f52daa --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/UiMessageSenderUtils.kt @@ -0,0 +1,9 @@ +package com.tangem.core.ui.utils + +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.error.UniversalError +import com.tangem.core.ui.message.dialog.Dialogs + +fun UiMessageSender.showErrorDialog(universalError: UniversalError) { + send(Dialogs.universalErrorDialog(universalError) {}) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_accepted.xml b/core/ui/src/main/res/drawable/ic_accepted_20.xml similarity index 100% rename from app/src/main/res/drawable/ic_accepted.xml rename to core/ui/src/main/res/drawable/ic_accepted_20.xml diff --git a/core/ui/src/main/res/drawable/ic_access_code.xml b/core/ui/src/main/res/drawable/ic_access_code_119.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_access_code.xml rename to core/ui/src/main/res/drawable/ic_access_code_119.xml diff --git a/core/ui/src/main/res/drawable/ic_nft_placeholder_76.xml b/core/ui/src/main/res/drawable/ic_nft_placeholder_76.xml new file mode 100644 index 0000000000..6e6a840d1f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_nft_placeholder_76.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_pepecoin_22.xml b/core/ui/src/main/res/drawable/ic_pepecoin_22.xml new file mode 100644 index 0000000000..d7a3b4222b --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_pepecoin_22.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_unticked.xml b/core/ui/src/main/res/drawable/ic_unticked_20.xml similarity index 100% rename from app/src/main/res/drawable/ic_unticked.xml rename to core/ui/src/main/res/drawable/ic_unticked_20.xml diff --git a/core/ui/src/main/res/drawable/img_pepecoin_22.xml b/core/ui/src/main/res/drawable/img_pepecoin_22.xml new file mode 100644 index 0000000000..6c6907e9d7 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_pepecoin_22.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_wallet_connect_76.xml b/core/ui/src/main/res/drawable/img_wallet_connect_76.xml new file mode 100644 index 0000000000..a24b3060f6 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_wallet_connect_76.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/core/ui/src/main/res/raw/anim_visa_loading.json b/core/ui/src/main/res/raw/anim_visa_loading.json new file mode 100644 index 0000000000..1e2b97912b --- /dev/null +++ b/core/ui/src/main/res/raw/anim_visa_loading.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":130,"w":512,"h":512,"nm":"Main","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"02","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28,"s":[-5]},{"i":{"x":[0.833],"y":[6]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.111]},"t":49,"s":[0]},{"i":{"x":[0.833],"y":[-4.233]},"o":{"x":[0.333],"y":[0]},"t":75,"s":[-90]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.109]},"t":90,"s":[-90]},{"t":118,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[375,137.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":28,"s":[137,374.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":36,"s":[137,374.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.111},"t":49,"s":[137,374.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":75,"s":[205,300,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.109},"t":90,"s":[205,300,0],"to":[0,0,0],"ti":[0,0,0]},{"t":118,"s":[137,374.875,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-50,-197.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":49,"s":[170,170]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":75,"s":[80,40]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":90,"s":[80,40]},{"t":118,"s":[170,170]}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[12]},{"i":{"x":[0.833],"y":[-5.802]},"o":{"x":[0.167],"y":[0]},"t":75,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.109]},"t":90,"s":[3]},{"t":118,"s":[120]}],"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[0.568627476692,0.568627476692,0.568627476692,1]},{"t":90,"s":[0.568627476692,0.568627476692,0.568627476692,1]}],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"t":75,"s":[0.568627476692,0.568627476692,0.568627476692,1]}],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":36,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[100]},{"t":118,"s":[0]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-50,-189.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":130,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"03","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28,"s":[-5]},{"i":{"x":[0.833],"y":[6]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.111]},"t":49,"s":[0]},{"i":{"x":[0.833],"y":[-4.233]},"o":{"x":[0.333],"y":[0]},"t":75,"s":[-90]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.109]},"t":90,"s":[-90]},{"t":118,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[137,374.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":28,"s":[375,137.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":36,"s":[375,137.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.111},"t":49,"s":[375,137.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":75,"s":[297,300,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.109},"t":90,"s":[297,300,0],"to":[0,0,0],"ti":[0,0,0]},{"t":118,"s":[375,137.5,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-203,39.75,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":49,"s":[170,170]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":75,"s":[80,40]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":90,"s":[80,40]},{"t":118,"s":[170,170]}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[120]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[3]},{"t":118,"s":[12]}],"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"t":118,"s":[0.811764717102,0.811764717102,0.811764717102,1]}],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"t":118,"s":[0.56862745098,0.56862745098,0.56862745098,1]}],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[100]},{"t":118,"s":[0]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-203,39.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":130,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"04","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28,"s":[-5]},{"i":{"x":[0.833],"y":[6]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.111]},"t":49,"s":[0]},{"i":{"x":[0.833],"y":[-4.233]},"o":{"x":[0.333],"y":[0]},"t":75,"s":[-90]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.109]},"t":90,"s":[-90]},{"t":118,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[375,374.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":28,"s":[137,137.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":36,"s":[137,137.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.111},"t":49,"s":[137,137.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":75,"s":[256,200,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.109},"t":90,"s":[256,200,0],"to":[0,0,0],"ti":[0,0,0]},{"t":118,"s":[375,374.875,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-119,-120.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":49,"s":[170,170]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":75,"s":[40,128]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":90,"s":[40,128]},{"t":118,"s":[170,170]}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[12]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[3]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[3]},{"t":118,"s":[12]}],"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[0.568627476692,0.568627476692,0.568627476692,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[0.568627476692,0.568627476692,0.568627476692,1]},{"t":118,"s":[0.811764717102,0.811764717102,0.811764717102,1]}],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[0.56862745098,0.56862745098,0.56862745098,1]},{"t":118,"s":[0.811764705882,0.811764705882,0.811764705882,1]}],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[100]},{"t":118,"s":[0]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-119,-120.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"fl","c":{"a":0,"k":[0.811764705882,0.811764705882,0.811764705882,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":36,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[100]},{"t":118,"s":[0]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":1,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":130,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"01","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28,"s":[-5]},{"i":{"x":[0.833],"y":[6]},"o":{"x":[0.333],"y":[0]},"t":36,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.111]},"t":49,"s":[0]},{"i":{"x":[0.833],"y":[6.233]},"o":{"x":[0.333],"y":[0]},"t":75,"s":[-90]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0.109]},"t":90,"s":[-90]},{"t":118,"s":[-180]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.167},"t":0,"s":[137,137.5,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.333,"y":0.333},"t":28,"s":[375,374.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":36,"s":[375,374.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.111},"t":49,"s":[375,374.875,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.333,"y":0.333},"t":75,"s":[256,256,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0.109},"t":90,"s":[256,256,0],"to":[0,0,0],"ti":[0,0,0]},{"t":118,"s":[137,137.5,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-119,-120.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":1,"k":[{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":49,"s":[170,170]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":75,"s":[270,440]},{"i":{"x":[0.833,0.833],"y":[0.833,0.833]},"o":{"x":[0.167,0.167],"y":[0.167,0.167]},"t":90,"s":[270,440]},{"t":118,"s":[170,170]}],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[12]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[48]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[48]},{"t":118,"s":[12]}],"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"t":49,"s":[0.811764705882,0.811764705882,0.811764705882,1]}],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[0.811764705882,0.811764705882,0.811764705882,1]},{"t":118,"s":[0.811764705882,0.811764705882,0.811764705882,1]}],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":90,"s":[100]},{"t":118,"s":[0]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-119,-120.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":130,"st":0,"ct":1,"bm":0}],"markers":[{"tm":28,"cm":"1","dr":0},{"tm":36,"cm":"2","dr":0},{"tm":49,"cm":"3","dr":0},{"tm":75,"cm":"4","dr":0},{"tm":90,"cm":"5","dr":0},{"tm":118,"cm":"6","dr":0}],"props":{}} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/transformer/Transformer.kt b/core/utils/src/main/java/com/tangem/utils/transformer/Transformer.kt index 9c271d0920..51b347095e 100644 --- a/core/utils/src/main/java/com/tangem/utils/transformer/Transformer.kt +++ b/core/utils/src/main/java/com/tangem/utils/transformer/Transformer.kt @@ -1,8 +1,15 @@ package com.tangem.utils.transformer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + /** * Transforms state to updated state. */ interface Transformer { fun transform(prevState: S): S +} + +fun MutableStateFlow.update(transformer: Transformer) { + update { transformer.transform(this.value) } } \ No newline at end of file diff --git a/data/card/src/main/java/com/tangem/data/card/DefaultCardSdkConfigRepository.kt b/data/card/src/main/java/com/tangem/data/card/DefaultCardSdkConfigRepository.kt index 84aa2bcf9e..61be7f90d6 100644 --- a/data/card/src/main/java/com/tangem/data/card/DefaultCardSdkConfigRepository.kt +++ b/data/card/src/main/java/com/tangem/data/card/DefaultCardSdkConfigRepository.kt @@ -6,8 +6,8 @@ import com.tangem.common.UserCodeType import com.tangem.common.core.CardIdDisplayFormat import com.tangem.common.core.UserCodeRequestPolicy import com.tangem.data.card.sdk.CardSdkProvider -import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.scan.ProductType /** @@ -70,4 +70,8 @@ internal class DefaultCardSdkConfigRepository( override fun setLinkedTerminal(isLinked: Boolean?) { sdk.config.linkedTerminal = isLinked } + + override fun setTangemApiProdEnvFlag(flag: Boolean) { + sdk.config.isTangemAttestationProdEnv = flag + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt index a0fde030cc..7e0a654891 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/NetworkOperations.kt @@ -301,6 +301,7 @@ private fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtra Blockchain.ApeChain, Blockchain.ApeChainTestnet, Blockchain.Scroll, Blockchain.ScrollTestnet, Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet, + Blockchain.Pepecoin, Blockchain.PepecoinTestnet, -> Network.TransactionExtrasType.NONE // endregion } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt index 24e9f844e5..a593d255a9 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/ResponseCryptoCurrenciesFactory.kt @@ -31,6 +31,14 @@ class ResponseCryptoCurrenciesFactory( .toList() } + fun createCurrencies(tokens: List, scanResponse: ScanResponse): List { + return tokens + .asSequence() + .mapNotNull { createCurrency(it, scanResponse) } + .distinctBy(CryptoCurrency::id) + .toList() + } + fun createCurrency(responseToken: UserTokensResponse.Token, scanResponse: ScanResponse): CryptoCurrency? { var blockchain = Blockchain.fromNetworkId(responseToken.networkId) if (blockchain == null || blockchain == Blockchain.Unknown) { diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt index 17f8dca358..028f1697e1 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt @@ -94,6 +94,8 @@ internal class DefaultFeedbackRepository( override fun getLogFile(): File? = appLogsStore.getFile() + override suspend fun getZipLogFile(): File? = appLogsStore.getZipFile() + override fun sendEmail(feedbackEmail: FeedbackEmail) { emailSender.send( EmailSender.Email( diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index 4b2f305bcf..91c229e3ef 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -335,7 +335,7 @@ internal class DefaultMarketsTokenRepository( ): T { return when (error) { is ApiResponseError.HttpException -> { - createEvent(MarketsDataAnalyticsEvent.Type.Http, error.code.code, error.message.orEmpty()) + createEvent(MarketsDataAnalyticsEvent.Type.Http, error.code.numericCode, error.message.orEmpty()) } is ApiResponseError.TimeoutException -> { createEvent(MarketsDataAnalyticsEvent.Type.Timeout, null, error.message.orEmpty()) diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt index 38d39f1843..2091fcf399 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketListConverter.kt @@ -28,9 +28,9 @@ internal object TokenMarketListConverter : Converter { + + override fun convert(value: NetworkStatusDM): SimpleNetworkStatus { + val address = NetworkAddressConverter(selectedAddress = value.selectedAddress) + .convert(value = value.availableAddresses) + + val status = when (value) { + is NetworkStatusDM.Verified -> { + NetworkStatus.Verified( + address = address, + amounts = NetworkAmountsConverter.convert(value = value.amounts), + pendingTransactions = mapOf(), + source = StatusSource.CACHE, + ) + } + is NetworkStatusDM.NoAccount -> { + NetworkStatus.NoAccount( + address = address, + amountToCreateAccount = value.amountToCreateAccount, + errorMessage = value.errorMessage, + source = StatusSource.CACHE, + ) + } + } + + return SimpleNetworkStatus( + id = SimpleNetworkStatus.Id( + networkId = value.networkId, + derivationPath = NetworkDerivationPathConverter.convert(value = value.derivationPath), + ), + value = status, + ) + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusFetcherModule.kt b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusFetcherModule.kt new file mode 100644 index 0000000000..85eb5b2848 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusFetcherModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.networks.di + +import com.tangem.data.networks.multi.DefaultMultiNetworkStatusFetcher +import com.tangem.data.networks.single.DefaultSingleNetworkStatusFetcher +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +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 NetworkStatusFetcherModule { + + @Binds + @Singleton + fun bindSingleNetworkStatusFetcher(impl: DefaultSingleNetworkStatusFetcher): SingleNetworkStatusFetcher + + @Binds + @Singleton + fun bindMultiNetworkStatusFetcher(impl: DefaultMultiNetworkStatusFetcher): MultiNetworkStatusFetcher +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusProducerFactoryModule.kt b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusProducerFactoryModule.kt new file mode 100644 index 0000000000..e029ba4c86 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusProducerFactoryModule.kt @@ -0,0 +1,28 @@ +package com.tangem.data.networks.di + +import com.tangem.data.networks.multi.DefaultMultiNetworkStatusProducer +import com.tangem.data.networks.single.DefaultSingleNetworkStatusProducer +import com.tangem.domain.networks.multi.MultiNetworkStatusProducer +import com.tangem.domain.networks.single.SingleNetworkStatusProducer +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 NetworkStatusProducerFactoryModule { + + @Binds + @Singleton + fun bindSingleNetworkStatusProducerFactory( + impl: DefaultSingleNetworkStatusProducer.Factory, + ): SingleNetworkStatusProducer.Factory + + @Binds + @Singleton + fun bindMultiNetworkStatusProducerFactory( + impl: DefaultMultiNetworkStatusProducer.Factory, + ): MultiNetworkStatusProducer.Factory +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusSupplierModule.kt b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusSupplierModule.kt new file mode 100644 index 0000000000..aea068fa04 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/di/NetworkStatusSupplierModule.kt @@ -0,0 +1,56 @@ +package com.tangem.data.networks.di + +import androidx.datastore.core.DataStore +import com.tangem.data.networks.store.DefaultNetworksStatusesStoreV2 +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.network.entity.NetworkStatusDM +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.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 NetworkStatusSupplierModule { + + @Provides + @Singleton + fun provideNetworksStatusesStoreV2( + persistenceNetworksStatusesStore: DataStore>>, + dispatchers: CoroutineDispatcherProvider, + ): NetworksStatusesStoreV2 { + return DefaultNetworksStatusesStoreV2( + runtimeStore = RuntimeSharedStore(), + persistenceDataStore = persistenceNetworksStatusesStore, + dispatchers = dispatchers, + ) + } + + @Provides + @Singleton + fun provideSingleNetworkStatusSupplier(factory: SingleNetworkStatusProducer.Factory): SingleNetworkStatusSupplier { + return object : SingleNetworkStatusSupplier( + factory = factory, + keyCreator = { + "single_network_status_${it.userWalletId.stringValue}_${it.network.id.value}_" + + it.network.derivationPath.value + }, + ) {} + } + + @Provides + @Singleton + fun provideMultiNetworkStatusSupplier(factory: MultiNetworkStatusProducer.Factory): MultiNetworkStatusSupplier { + return object : MultiNetworkStatusSupplier( + factory = factory, + keyCreator = { "multi_networks_statuses_${it.userWalletId.stringValue}" }, + ) {} + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/models/SimpleNetworkStatus.kt b/data/networks/src/main/java/com/tangem/data/networks/models/SimpleNetworkStatus.kt new file mode 100644 index 0000000000..6ee9a975b9 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/models/SimpleNetworkStatus.kt @@ -0,0 +1,27 @@ +package com.tangem.data.networks.models + +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkStatus + +/** + * Simplified model of network status. Like [NetworkStatus] but without metadata. + * + * @property id identifier + * @property value value + */ +internal data class SimpleNetworkStatus(val id: Id, val value: NetworkStatus.Value) { + + /** + * Identifier of [SimpleNetworkStatus] + * + * @property networkId network id + * @property derivationPath derivation path + */ + data class Id(val networkId: Network.ID, val derivationPath: Network.DerivationPath) { + + constructor(network: Network) : this( + networkId = network.id, + derivationPath = network.derivationPath, + ) + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt new file mode 100644 index 0000000000..c807f56761 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt @@ -0,0 +1,55 @@ +package com.tangem.data.networks.multi + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject + +/** + * Default implementation of [MultiNetworkStatusFetcher] + * + * @property singleNetworkStatusFetcher single network status fetcher + * @property networksStatusesStore networks statuses store + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiNetworkStatusFetcher @Inject constructor( + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val networksStatusesStore: NetworksStatusesStoreV2, +) : MultiNetworkStatusFetcher { + + override suspend fun invoke(params: MultiNetworkStatusFetcher.Params): Either = either { + // Optimization! + // Every singleNetworkStatusFetcher with applyRefresh as true will refresh every network in the store. + // So if we update all networks at once, it will be more efficient. + networksStatusesStore.refresh(userWalletId = params.userWalletId, networks = params.networks) + + val result = coroutineScope { + params.networks + .map { + async { + singleNetworkStatusFetcher( + params = SingleNetworkStatusFetcher.Params( + userWalletId = params.userWalletId, + network = it, + applyRefresh = false, + ), + ) + } + } + .awaitAll() + } + + val failedResult = result.firstOrNull { it.isLeft() } + + ensure(failedResult == null) { + IllegalStateException("Failed to fetch network statuses") + } + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt new file mode 100644 index 0000000000..84acf20972 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducer.kt @@ -0,0 +1,67 @@ +package com.tangem.data.networks.multi + +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.getNetwork +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.networks.multi.MultiNetworkStatusProducer +import com.tangem.domain.tokens.model.NetworkStatus +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.* +import timber.log.Timber + +/** + * Default implementation of [MultiNetworkStatusProducer] + * + * @property params params + * @property networksStatusesStore networks statuses store + * @property userWalletsStore user wallets store + * @property excludedBlockchains excluded blockchains + * @property dispatchers dispatchers + */ +internal class DefaultMultiNetworkStatusProducer @AssistedInject constructor( + @Assisted val params: MultiNetworkStatusProducer.Params, + private val networksStatusesStore: NetworksStatusesStoreV2, + private val userWalletsStore: UserWalletsStore, + private val excludedBlockchains: ExcludedBlockchains, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiNetworkStatusProducer { + + override val fallback: Set + get() = setOf() + + override fun produce(): Flow> { + return networksStatusesStore + .get(userWalletId = params.userWalletId) + .mapNotNull { statuses -> + val userWallet = userWalletsStore.getSyncOrNull(params.userWalletId) + + if (userWallet == null) { + Timber.e("Unable to get UserWallet with provided ID: ${params.userWalletId}") + return@mapNotNull null + } + + statuses.mapNotNullTo(hashSetOf()) { status -> + val network = getNetwork( + networkId = status.id.networkId, + derivationPath = status.id.derivationPath, + scanResponse = userWallet.scanResponse, + excludedBlockchains = excludedBlockchains, + ) ?: return@mapNotNullTo null + + NetworkStatus(network = network, value = status.value) + } + } + .distinctUntilChanged() + .onEmpty { emit(value = hashSetOf()) } + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : MultiNetworkStatusProducer.Factory { + override fun create(params: MultiNetworkStatusProducer.Params): DefaultMultiNetworkStatusProducer + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt new file mode 100644 index 0000000000..6ded8b6524 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt @@ -0,0 +1,117 @@ +package com.tangem.data.networks.single + +import arrow.core.Either +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.data.tokens.utils.CardCryptoCurrenciesFactory +import com.tangem.data.tokens.utils.NetworkStatusFactory +import com.tangem.datasource.api.tangemTech.models.UserTokensResponse +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.userwallet.UserWalletsStore +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.demo.DemoConfig +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +/** + * Default implementation of [SingleNetworkStatusFetcher] + * + * @param excludedBlockchains excluded blockchains + * @property walletManagersFacade wallet managers facade + * @property networksStatusesStore networks statuses store + * @property userWalletsStore user wallets store + * @property appPreferencesStore app preferences store + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleNetworkStatusFetcher @Inject constructor( + excludedBlockchains: ExcludedBlockchains, + private val walletManagersFacade: WalletManagersFacade, + private val networksStatusesStore: NetworksStatusesStoreV2, + private val userWalletsStore: UserWalletsStore, + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleNetworkStatusFetcher { + + private val demoConfig = DemoConfig() + private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(demoConfig, excludedBlockchains) + private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains) + private val networkStatusFactory = NetworkStatusFactory() + + override suspend fun invoke(params: SingleNetworkStatusFetcher.Params) = Either.catchOn(dispatchers.default) { + if (params.applyRefresh) { + networksStatusesStore.refresh(userWalletId = params.userWalletId, network = params.network) + } + + val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId) + val networkCurrencies = createCurrencies(userWallet = userWallet, network = params.network) + + val result = withContext(dispatchers.io) { + walletManagersFacade.update( + userWalletId = params.userWalletId, + network = params.network, + extraTokens = networkCurrencies + .filterIsInstance() + .toSet(), + ) + } + + val status = networkStatusFactory.createNetworkStatus( + network = params.network, + result = result, + currencies = networkCurrencies.toSet(), + ) + + networksStatusesStore.storeActual(userWalletId = params.userWalletId, value = status) + } + .onLeft { + Timber.e("Failed to fetch network status for $params: $it") + networksStatusesStore.storeError(userWalletId = params.userWalletId, network = params.network) + } + + private suspend fun createCurrencies(userWallet: UserWallet, network: Network): List { + val blockchain = Blockchain.fromNetworkId(networkId = network.backendId) + + // multi-currency wallet + if (userWallet.isMultiCurrency) return getMultiWalletCurrencies(userWallet = userWallet, network = network) + + // check if the blockchain of single-currency wallet is the same as network + val cardBlockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain() + if (cardBlockchain != blockchain) return emptyList() + + // single-currency wallet with token (NODL) + if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) { + return cardCurrenciesFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet.scanResponse) + } + + // single-currency wallet + return cardCurrenciesFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet.scanResponse).let(::listOf) + } + + private suspend fun getMultiWalletCurrencies(userWallet: UserWallet, network: Network): List { + val response = appPreferencesStore.getObjectSyncOrNull( + key = PreferencesKeys.getUserTokensKey(userWallet.walletId.stringValue), + ) ?: return emptyList() + + return responseCurrenciesFactory.createCurrencies( + tokens = response.tokens.filter { + it.networkId == network.backendId && it.derivationPath == network.derivationPath.value + }, + scanResponse = userWallet.scanResponse, + ) + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt new file mode 100644 index 0000000000..5438949c19 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducer.kt @@ -0,0 +1,49 @@ +package com.tangem.data.networks.single + +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.tokens.model.NetworkStatus +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 [SingleNetworkStatusProducer] + * + * @property params params + * @property multiNetworkStatusSupplier multi network status supplier + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultSingleNetworkStatusProducer @AssistedInject constructor( + @Assisted val params: SingleNetworkStatusProducer.Params, + private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleNetworkStatusProducer { + + override val fallback: NetworkStatus + get() = NetworkStatus(network = params.network, value = NetworkStatus.Unreachable(address = null)) + + override fun produce(): Flow { + return multiNetworkStatusSupplier( + params = MultiNetworkStatusProducer.Params(params.userWalletId), + ) + .mapNotNull { statuses -> + statuses.firstOrNull { it.network == params.network } + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleNetworkStatusProducer.Factory { + override fun create(params: SingleNetworkStatusProducer.Params): DefaultSingleNetworkStatusProducer + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStoreV2.kt b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStoreV2.kt new file mode 100644 index 0000000000..dedbcb34e5 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/store/DefaultNetworksStatusesStoreV2.kt @@ -0,0 +1,157 @@ +package com.tangem.data.networks.store + +import androidx.datastore.core.DataStore +import com.tangem.data.networks.converters.SimpleNetworkStatusConverter +import com.tangem.data.networks.models.SimpleNetworkStatus +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.network.converter.NetworkStatusDataModelConverter +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkStatus +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 WalletIdWithSimpleStatus = Map> +internal typealias WalletIdWithStatusDM = Map> + +/** + * Default implementation of [NetworksStatusesStoreV2] + * + * @property runtimeStore runtime store + * @property persistenceDataStore persistence store + * @param dispatchers dispatchers + */ +internal class DefaultNetworksStatusesStoreV2( + private val runtimeStore: RuntimeSharedStore, + private val persistenceDataStore: DataStore, + dispatchers: CoroutineDispatcherProvider, +) : NetworksStatusesStoreV2 { + + private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) + + init { + scope.launch { + val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch + + runtimeStore.store( + value = cachedStatuses.mapValues { (_, statuses) -> + SimpleNetworkStatusConverter.convertSet(statuses) + }, + ) + } + } + + override fun get(userWalletId: UserWalletId): Flow> { + return runtimeStore.get().mapNotNull { it[userWalletId.stringValue] } + } + + override suspend fun refresh(userWalletId: UserWalletId, network: Network) { + updateStatusSourceInRuntime(userWalletId = userWalletId, network = network, source = StatusSource.CACHE) + } + + override suspend fun refresh(userWalletId: UserWalletId, networks: Set) { + val simpleStatusIds = networks.map(SimpleNetworkStatus::Id) + + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + val storedStatuses = this[userWalletId.stringValue].orEmpty() + + val statuses = simpleStatusIds.mapTo(hashSetOf()) { simpleStatusId -> + val status = storedStatuses.firstOrNull { it.id == simpleStatusId } + ?: createDefaultStatus(id = simpleStatusId) + + status.copy( + value = status.value.copySealed(source = StatusSource.CACHE), + ) + } + + val updatedStatuses = storedStatuses.addOrReplace(statuses) { old, new -> old.id == new.id } + + put(key = userWalletId.stringValue, value = updatedStatuses) + } + } + } + + override suspend fun storeActual(userWalletId: UserWalletId, value: NetworkStatus) { + if (value.value.source != StatusSource.ACTUAL) { + error("Method storeActual can be called only with StatusSource.ACTUAL") + } + + coroutineScope { + launch { storeInRuntime(userWalletId = userWalletId, status = value) } + launch { storeInPersistence(userWalletId = userWalletId, status = value) } + } + } + + override suspend fun storeError(userWalletId: UserWalletId, network: Network) { + updateStatusSourceInRuntime(userWalletId = userWalletId, network = network, source = StatusSource.ONLY_CACHE) + } + + private suspend fun updateStatusSourceInRuntime( + userWalletId: UserWalletId, + network: Network, + source: StatusSource, + ) { + val simpleStatusId = SimpleNetworkStatus.Id(network) + + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + val status = this[userWalletId.stringValue].orEmpty() + .firstOrNull { it.id == simpleStatusId } + ?: createDefaultStatus(id = simpleStatusId) + + val updatedStatus = status.copy( + value = status.value.copySealed(source = source), + ) + + val updatedStatuses = this[userWalletId.stringValue].orEmpty() + .addOrReplace(updatedStatus) { it.id == simpleStatusId } + + put(key = userWalletId.stringValue, value = updatedStatuses) + } + } + } + + private suspend fun storeInRuntime(userWalletId: UserWalletId, status: NetworkStatus) { + val simpleStatusId = SimpleNetworkStatus.Id(network = status.network) + + runtimeStore.update(default = emptyMap()) { stored -> + stored.toMutableMap().apply { + val simpleStatus = SimpleNetworkStatus(id = simpleStatusId, value = status.value) + + val updatedStatuses = this[userWalletId.stringValue].orEmpty() + .addOrReplace(simpleStatus) { it.id == simpleStatusId } + + put(key = userWalletId.stringValue, value = updatedStatuses) + } + } + } + + private suspend fun storeInPersistence(userWalletId: UserWalletId, status: NetworkStatus) { + // Converter will return null if the network status is not supported + val statusDM = NetworkStatusDataModelConverter.convert(value = status) ?: return + + persistenceDataStore.updateData { storedStatuses -> + storedStatuses.toMutableMap().apply { + val updatedValues = this[userWalletId.stringValue].orEmpty().addOrReplace(statusDM) { + it.networkId == statusDM.networkId && it.derivationPath == statusDM.derivationPath + } + + this[userWalletId.stringValue] = updatedValues + } + } + } + + private fun createDefaultStatus(id: SimpleNetworkStatus.Id): SimpleNetworkStatus { + return SimpleNetworkStatus(id = id, value = NetworkStatus.Unreachable(address = null)) + } +} \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStoreV2.kt b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStoreV2.kt new file mode 100644 index 0000000000..3810940ad9 --- /dev/null +++ b/data/networks/src/main/java/com/tangem/data/networks/store/NetworksStatusesStoreV2.kt @@ -0,0 +1,26 @@ +package com.tangem.data.networks.store + +import com.tangem.data.networks.models.SimpleNetworkStatus +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkStatus +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow + +/** Store of [NetworkStatus]'es set */ +internal interface NetworksStatusesStoreV2 { + + /** Get statuses by [userWalletId] */ + fun get(userWalletId: UserWalletId): Flow> + + /** Refresh status of [network] by [userWalletId] */ + suspend fun refresh(userWalletId: UserWalletId, network: Network) + + /** Refresh statuses of [networks] by [userWalletId] */ + suspend fun refresh(userWalletId: UserWalletId, networks: Set) + + /** Store actual [NetworkStatus] by [userWalletId] */ + suspend fun storeActual(userWalletId: UserWalletId, value: NetworkStatus) + + /** Store error by [userWalletId] and [network] */ + suspend fun storeError(userWalletId: UserWalletId, network: Network) +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/NetworkStatusExt.kt b/data/networks/src/test/java/com/tangem/data/networks/NetworkStatusExt.kt new file mode 100644 index 0000000000..1b19c1bc0f --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/NetworkStatusExt.kt @@ -0,0 +1,17 @@ +package com.tangem.data.networks + +import com.tangem.data.networks.models.SimpleNetworkStatus +import com.tangem.datasource.local.network.converter.NetworkStatusDataModelConverter +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.domain.tokens.model.NetworkStatus + +internal fun NetworkStatus.toSimple(): SimpleNetworkStatus { + return SimpleNetworkStatus( + id = SimpleNetworkStatus.Id(network = network), + value = value, + ) +} + +internal fun NetworkStatus.toDataModel(): NetworkStatusDM? { + return NetworkStatusDataModelConverter.convert(value = this) +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt new file mode 100644 index 0000000000..ab79da0dd1 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt @@ -0,0 +1,133 @@ +package com.tangem.data.networks.multi + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.wallets.models.UserWalletId +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiNetworkStatusFetcherTest { + + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk() + private val networksStatusesStore: NetworksStatusesStoreV2 = mockk(relaxed = true) + + private val fetcher = DefaultMultiNetworkStatusFetcher( + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + networksStatusesStore = networksStatusesStore, + ) + + @Test + fun `fetch networks statuses successfully`() = runTest { + val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar) + + val ethParams = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = ethereumAndStellar.first(), + applyRefresh = false, + ) + + val stellarParams = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = ethereumAndStellar.last(), + applyRefresh = false, + ) + + coEvery { singleNetworkStatusFetcher(ethParams) } returns Unit.right() + coEvery { singleNetworkStatusFetcher(stellarParams) } returns Unit.right() + + val actual = fetcher(params) + + coVerify { + networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar) + singleNetworkStatusFetcher(ethParams) + singleNetworkStatusFetcher(stellarParams) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch networks statuses failure if one of them fails`() = runTest { + val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar) + + val ethParams = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = ethereumAndStellar.first(), + applyRefresh = false, + ) + + val stellarParams = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = ethereumAndStellar.last(), + applyRefresh = false, + ) + + val ethException = IllegalStateException("eth") + coEvery { singleNetworkStatusFetcher(ethParams) } returns ethException.left() + coEvery { singleNetworkStatusFetcher(stellarParams) } returns Unit.right() + + val actual = fetcher(params) + + coVerify { + networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar) + singleNetworkStatusFetcher(ethParams) + singleNetworkStatusFetcher(stellarParams) + } + + val expected = IllegalStateException("Failed to fetch network statuses") + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + @Test + fun `fetch networks statuses failure if all of them fails`() = runTest { + val params = MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = ethereumAndStellar) + + val ethParams = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = ethereumAndStellar.first(), + applyRefresh = false, + ) + + val stellarParams = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = ethereumAndStellar.last(), + applyRefresh = false, + ) + + coEvery { singleNetworkStatusFetcher(ethParams) } returns IllegalStateException("eth").left() + coEvery { singleNetworkStatusFetcher(stellarParams) } returns IllegalStateException("stellar").left() + + val actual = fetcher(params) + + coVerify { + networksStatusesStore.refresh(userWalletId = userWalletId, networks = ethereumAndStellar) + singleNetworkStatusFetcher(ethParams) + singleNetworkStatusFetcher(stellarParams) + } + + val expected = IllegalStateException("Failed to fetch network statuses") + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java) + Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message) + } + + private companion object { + val userWalletId = UserWalletId("011") + val ethereumAndStellar = MockCryptoCurrencyFactory().ethereumAndStellar.map { it.network }.toSet() + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..274e82eae5 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusProducerTest.kt @@ -0,0 +1,276 @@ +package com.tangem.data.networks.multi + +import com.google.common.truth.Truth +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.common.test.domain.network.MockNetworkStatusFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.networks.models.SimpleNetworkStatus +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.data.networks.toSimple +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.common.configs.GenericCardConfig +import com.tangem.domain.networks.multi.MultiNetworkStatusProducer +import com.tangem.domain.tokens.model.NetworkStatus +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.Before +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiNetworkStatusProducerTest { + + private val params = MultiNetworkStatusProducer.Params(userWalletId = userWallet.walletId) + + private val networksStatusesStore = mockk() + private val userWalletsStore = mockk() + private val excludedBlockchains = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val producer = DefaultMultiNetworkStatusProducer( + params = params, + networksStatusesStore = networksStatusesStore, + userWalletsStore = userWalletsStore, + excludedBlockchains = excludedBlockchains, + dispatchers = dispatchers, + ) + + @Before + fun setup() { + every { excludedBlockchains.contains(any()) } returns false + } + + @Test + fun `test that flow is mapped for user wallet id from params`() = runTest { + val statuses = setOf( + MockNetworkStatusFactory.createVerified(ethNetwork), + MockNetworkStatusFactory.createVerified(cardanoNetwork), + ) + + val networksStatusesFlow = flowOf(statuses.map(NetworkStatus::toSimple).toSet()) + + every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow + every { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + + val actual = producer.produce() + + // check after producer.produce() + verify { networksStatusesStore.get(params.userWalletId) } + + val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + // Check after flow was observed by subscriber (getEmittedValues). + // Otherwise, userWalletsStore.getSyncOrNull is not called. + verify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values.first()).isEqualTo(statuses) + } + + @Test + fun `test that flow is updated if statuses are updated`() = runTest { + val networksStatusesFlow = MutableSharedFlow>(replay = 2) + + every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow + every { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + + val actual = producer.produce() + + // check after producer.produce() + verify { networksStatusesStore.get(params.userWalletId) } + + // first emit + val statuses = setOf( + MockNetworkStatusFactory.createUnreachable(ethNetwork), + MockNetworkStatusFactory.createUnreachable(cardanoNetwork), + ) + + networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + // Check after flow was observed by subscriber (getEmittedValues). + // Otherwise, userWalletsStore.getSyncOrNull is not called. + verify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1.first()).isEqualTo(statuses) + + // second emit + val updatedStatuses = setOf( + MockNetworkStatusFactory.createVerified(ethNetwork), + MockNetworkStatusFactory.createVerified(cardanoNetwork), + ) + + networksStatusesFlow.emit(updatedStatuses.map(NetworkStatus::toSimple).toSet()) + + val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + // Check after flow was observed by subscriber (getEmittedValues). + // Otherwise, userWalletsStore.getSyncOrNull is not called. + verify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + val expected = listOf(statuses, updatedStatuses) + Truth.assertThat(values2.size).isEqualTo(2) + Truth.assertThat(values2).isEqualTo(expected) + } + + @Test + fun `test that flow is filtered the same status`() = runTest { + val networksStatusesFlow = MutableSharedFlow>(replay = 2) + + every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow + every { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + + val actual = producer.produce() + + // check after producer.produce() + verify { networksStatusesStore.get(params.userWalletId) } + + // first emit + val statuses = setOf( + MockNetworkStatusFactory.createUnreachable(ethNetwork), + MockNetworkStatusFactory.createUnreachable(cardanoNetwork), + ) + + networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + // Check after flow was observed by subscriber (getEmittedValues). + // Otherwise, userWalletsStore.getSyncOrNull is not called. + verify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1.first()).isEqualTo(statuses) + + // second emit + networksStatusesFlow.emit(statuses.map(NetworkStatus::toSimple).toSet()) + + val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + // Check after flow was observed by subscriber (getEmittedValues). + // Otherwise, userWalletsStore.getSyncOrNull is not called. + verify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2.first()).isEqualTo(statuses) + } + + @Test + fun `test if flow throws exception`() = runTest { + val exception = IllegalStateException() + val statuses = setOf( + MockNetworkStatusFactory.createUnreachable(ethNetwork), + MockNetworkStatusFactory.createUnreachable(cardanoNetwork), + ) + + val innerFlow = MutableStateFlow(value = false) + val networksStatusesFlow = flow { + if (innerFlow.value) { + emit(statuses.map(NetworkStatus::toSimple).toSet()) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow + every { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + + val actual = producer.produceWithFallback() + + // check after producer.produce() + verify { networksStatusesStore.get(params.userWalletId) } + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + // Check after flow was observed by subscriber (getEmittedValues). + // Otherwise, userWalletsStore.getSyncOrNull is not called. + verify(inverse = true) { userWalletsStore.getSyncOrNull(params.userWalletId) } + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(emptySet())) + + innerFlow.emit(value = true) + + val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + // Check after flow was observed by subscriber (getEmittedValues). + // Otherwise, userWalletsStore.getSyncOrNull is not called. + verify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + Truth.assertThat(values2.size).isEqualTo(1) + Truth.assertThat(values2).isEqualTo(listOf(statuses)) + } + + @Test + fun `test that flow is empty`() = runTest { + every { networksStatusesStore.get(params.userWalletId) } returns emptyFlow() + every { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + + val actual = producer.produce() + + // check after producer.produce() + verify { networksStatusesStore.get(params.userWalletId) } + + val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + verify(inverse = true) { userWalletsStore.getSyncOrNull(params.userWalletId) } + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + @Test + fun `test if network is null`() = runTest { + val statuses = setOf( + MockNetworkStatusFactory.createVerified(ethNetwork), + MockNetworkStatusFactory.createVerified(cardanoNetwork), + ) + + val networksStatusesFlow = flowOf(statuses.map(NetworkStatus::toSimple).toSet()) + + every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow + every { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet + every { excludedBlockchains.contains(any()) } returns true // cause network is null + + val actual = producer.produce() + + // check after producer.produce() + verify { networksStatusesStore.get(params.userWalletId) } + + val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + // Check after flow was observed by subscriber (getEmittedValues). + // Otherwise, userWalletsStore.getSyncOrNull is not called. + verify { userWalletsStore.getSyncOrNull(params.userWalletId) } + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + private companion object { + + val scanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(2), + derivedKeys = emptyMap(), + ) + + val userWallet = MockUserWalletFactory.create(scanResponse = scanResponse) + + val ethNetwork = MockCryptoCurrencyFactory(scanResponse = scanResponse).ethereum.network.copy( + canHandleTokens = true, + ) + + val cardanoNetwork = MockCryptoCurrencyFactory(scanResponse = scanResponse).cardano.network + } +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcherTest.kt new file mode 100644 index 0000000000..051cbca91b --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcherTest.kt @@ -0,0 +1,125 @@ +package com.tangem.data.networks.single + +import com.google.common.truth.Truth +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.networks.store.NetworksStatusesStoreV2 +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.tokens.model.NetworkStatus +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult +import com.tangem.domain.wallets.models.UserWalletId +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 + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultSingleNetworkStatusFetcherTest { + + private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val networksStatusesStore: NetworksStatusesStoreV2 = mockk(relaxed = true) + private val userWalletsStore: UserWalletsStore = mockk(relaxed = true) + + private val fetcher = DefaultSingleNetworkStatusFetcher( + excludedBlockchains = mockk(relaxed = true), + walletManagersFacade = walletManagersFacade, + networksStatusesStore = networksStatusesStore, + userWalletsStore = userWalletsStore, + appPreferencesStore = mockk(relaxed = true), + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `fetch network status successfully`() = runTest { + val params = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + applyRefresh = true, + ) + + val result = UpdateWalletManagerResult.MissedDerivation + coEvery { walletManagersFacade.update(userWalletId, network, emptySet()) } returns result + + val actual = fetcher(params) + + coVerifyOrder { + networksStatusesStore.refresh(userWalletId = userWalletId, network = network) + userWalletsStore.getSyncStrict(key = userWalletId) + walletManagersFacade.update(userWalletId, network, emptySet()) + networksStatusesStore.storeActual( + userWalletId = userWalletId, + value = NetworkStatus(network, NetworkStatus.MissedDerivation), + ) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch network status failure`() = runTest { + val params = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + applyRefresh = true, + ) + + val exception = IllegalStateException() + coEvery { userWalletsStore.getSyncStrict(key = userWalletId) } throws exception + + val actual = fetcher(params) + + coVerifyOrder { + networksStatusesStore.refresh(userWalletId = userWalletId, network = network) + userWalletsStore.getSyncStrict(key = userWalletId) + networksStatusesStore.storeError(userWalletId = userWalletId, network = network) + } + + coVerify(inverse = true) { + walletManagersFacade.update(userWalletId = any(), network = any(), extraTokens = any()) + networksStatusesStore.storeActual(userWalletId = any(), value = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + Truth.assertThat(actual.leftOrNull()).isEqualTo(exception) + } + + @Test + fun `fetch network status if applyRefresh is false`() = runTest { + val params = SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + applyRefresh = false, + ) + + val result = UpdateWalletManagerResult.MissedDerivation + coEvery { walletManagersFacade.update(userWalletId, network, emptySet()) } returns result + + val actual = fetcher(params) + + coVerifyOrder { + userWalletsStore.getSyncStrict(key = userWalletId) + walletManagersFacade.update(userWalletId, network, emptySet()) + networksStatusesStore.storeActual( + userWalletId = userWalletId, + value = NetworkStatus(network, NetworkStatus.MissedDerivation), + ) + } + + coVerify(inverse = true) { + networksStatusesStore.refresh(userWalletId = any(), network = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + private companion object { + val userWalletId = UserWalletId("011") + val network = MockCryptoCurrencyFactory().ethereum.network + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..98894ea109 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusProducerTest.kt @@ -0,0 +1,181 @@ +package com.tangem.data.networks.single + +import com.google.common.truth.Truth +import com.tangem.common.test.domain.network.MockNetworkStatusFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.test.utils.getEmittedValues +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.tokens.model.NetworkStatus +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 DefaultSingleNetworkStatusProducerTest { + + private val params = SingleNetworkStatusProducer.Params( + userWalletId = UserWalletId(stringValue = "011"), + network = ethereum, + ) + + private val multiNetworkStatusSupplier = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val producer = DefaultSingleNetworkStatusProducer( + params = params, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + dispatchers = dispatchers, + ) + + @Test + fun `test that flow is mapped for network from params`() = runTest { + val status = MockNetworkStatusFactory.createMissedDerivation(params.network) + val expected = flowOf( + setOf( + status, + MockNetworkStatusFactory.createMissedDerivation(cardano), + ), + ) + + val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + + val actual = producer.produce() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(status)) + } + + @Test + fun `test that flow is updated if network status is updated`() = runTest { + val expected = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + // first emit + val status = MockNetworkStatusFactory.createMissedDerivation(params.network) + expected.emit(value = setOf(status)) + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(status)) + + // second emit + val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null)) + expected.emit(value = setOf(updatedStatus)) + + val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = 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 expected = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + // first emit + val status = MockNetworkStatusFactory.createMissedDerivation(params.network) + expected.emit(value = setOf(status)) + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(status)) + + // second emit + expected.emit(value = setOf(status)) + + val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = 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 = MockNetworkStatusFactory.createMissedDerivation(params.network) + + val innerFlow = MutableStateFlow(value = false) + val expected = flow { + if (innerFlow.value) { + emit(setOf(status)) + } else { + throw exception + } + } + .buffer(capacity = 5) + + val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + + val actual = producer.produceWithFallback() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network) + Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus)) + + innerFlow.emit(value = true) + + val values2 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = 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 expected = flowOf( + setOf(MockNetworkStatusFactory.createMissedDerivation(cardano)), + ) + + val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId) + every { multiNetworkStatusSupplier(multiParams) } returns expected + + val actual = producer.produce() + + verify { multiNetworkStatusSupplier(multiParams) } + + val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values.size).isEqualTo(0) + } + + private companion object { + + val mocks = MockCryptoCurrencyFactory() + + val ethereum = mocks.ethereum.network + val cardano = mocks.cardano.network + } +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/NetworkStatusesStoreUpdateMethodsTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/NetworkStatusesStoreUpdateMethodsTest.kt new file mode 100644 index 0000000000..57aea3c1e2 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/store/NetworkStatusesStoreUpdateMethodsTest.kt @@ -0,0 +1,255 @@ +package com.tangem.data.networks.store + +import com.google.common.truth.Truth +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.domain.network.MockNetworkStatusFactory +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.networks.models.SimpleNetworkStatus +import com.tangem.data.networks.toDataModel +import com.tangem.data.networks.toSimple +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.network.entity.NetworkStatusDM +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.NetworkStatus +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 NetworkStatusesStoreUpdateMethodsTest { + + private val runtimeStore = RuntimeSharedStore() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultNetworksStatusesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `refresh the single network if runtime store is empty`() = runTest { + store.refresh(userWalletId = userWalletId, network = network) + + val runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + SimpleNetworkStatus( + id = SimpleNetworkStatus.Id(network), + value = NetworkStatus.Unreachable(address = null), + ), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the single network if runtime store contains status with this network`() = runTest { + val status = MockNetworkStatusFactory.createVerified().toSimple() + + runtimeStore.store( + value = mapOf(userWalletId.stringValue to setOf(status)), + ) + + store.refresh(userWalletId = userWalletId, network = network) + + val runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + status.copy(value = status.value.copySealed(source = StatusSource.CACHE)), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the multi networks if runtime store is empty`() = runTest { + store.refresh(userWalletId = userWalletId, networks = networks) + + val runtimeExpected = mapOf( + userWalletId.stringValue to networks.mapTo(hashSetOf()) { + SimpleNetworkStatus( + id = SimpleNetworkStatus.Id(it), + value = NetworkStatus.Unreachable(address = null), + ) + }, + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh the multi networks if runtime store contains status with this network`() = runTest { + val firstStatus = MockNetworkStatusFactory.createVerified(network = networks.first()).toSimple() + val secondStatus = MockNetworkStatusFactory.createVerified(network = networks.last()).toSimple() + + runtimeStore.store( + value = mapOf(userWalletId.stringValue to setOf(firstStatus, secondStatus)), + ) + + store.refresh(userWalletId = userWalletId, networks = networks) + + val runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + firstStatus.copy(value = firstStatus.value.copySealed(source = StatusSource.CACHE)), + secondStatus.copy(value = secondStatus.value.copySealed(source = StatusSource.CACHE)), + ), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store actual with any status sources`() = runTest { + val expectedErrorMessage = "Method storeActual can be called only with StatusSource.ACTUAL" + + // #1: StatusSource.ACTUAL + val status = MockNetworkStatusFactory.createVerified().copy( + value = MockNetworkStatusFactory.createVerified().value.copySealed(source = StatusSource.ACTUAL), + ) + + val actual = runCatching { store.storeActual(userWalletId, status) } + + Truth.assertThat(actual.isSuccess).isTrue() + + // #2: StatusSource.CACHE + val cacheStatus = MockNetworkStatusFactory.createVerified().copy( + value = MockNetworkStatusFactory.createVerified().value.copySealed(source = StatusSource.CACHE), + ) + + val cacheActual = runCatching { store.storeActual(userWalletId, cacheStatus) } + + Truth.assertThat(cacheActual.isFailure).isTrue() + Truth.assertThat(cacheActual.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + Truth.assertThat(cacheActual.exceptionOrNull()).hasMessageThat().isEqualTo(expectedErrorMessage) + + // #3: StatusSource.ONLY_CACHE + val onlyCacheStatus = MockNetworkStatusFactory.createVerified().copy( + value = MockNetworkStatusFactory.createVerified().value.copySealed(source = StatusSource.ONLY_CACHE), + ) + + val onlyCacheActual = runCatching { store.storeActual(userWalletId, onlyCacheStatus) } + + Truth.assertThat(onlyCacheActual.isFailure).isTrue() + Truth.assertThat(onlyCacheActual.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + Truth.assertThat(onlyCacheActual.exceptionOrNull()).hasMessageThat().isEqualTo(expectedErrorMessage) + } + + @Test + fun `store actual if runtime and cache stores contain status with this network`() = runTest { + val prevStatus = MockNetworkStatusFactory.createVerified().copy( + value = MockNetworkStatusFactory.createVerified().value.copySealed(source = StatusSource.ONLY_CACHE), + ) + + runtimeStore.store( + value = mapOf(userWalletId.stringValue to setOf(prevStatus.toSimple())), + ) + + persistenceStore.updateData { + it.toMutableMap().apply { + put(userWalletId.stringValue, setOf(prevStatus.toDataModel()!!)) + } + } + + val newStatus = MockNetworkStatusFactory.createVerified().copy( + value = MockNetworkStatusFactory.createVerified().value.copySealed(source = StatusSource.ACTUAL), + ) + + store.storeActual(userWalletId, newStatus) + + val runtimeExpected = mapOf( + userWalletId.stringValue to setOf(newStatus.toSimple()), + ) + + val persistenceExpected = mapOf( + userWalletId.stringValue to setOf(newStatus.toDataModel()!!), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected) + } + + @Test + fun `store actual unreachable status if cache store contains verified status`() = runTest { + val prevStatus = MockNetworkStatusFactory.createVerified() + + runtimeStore.store( + value = mapOf(userWalletId.stringValue to setOf(prevStatus.toSimple())), + ) + + persistenceStore.updateData { + it.toMutableMap().apply { + put(userWalletId.stringValue, setOf(prevStatus.toDataModel()!!)) + } + } + + val newStatus = MockNetworkStatusFactory.createUnreachable() + + store.storeActual(userWalletId, newStatus) + + val runtimeExpected = mapOf( + userWalletId.stringValue to setOf(newStatus.toSimple()), + ) + + val persistenceExpected = mapOf( + userWalletId.stringValue to setOf(prevStatus.toDataModel()!!), + ) + + 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, network = network) + + val runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + SimpleNetworkStatus( + id = SimpleNetworkStatus.Id(network), + value = NetworkStatus.Unreachable(address = null), + ), + ), + ) + + 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 = MockNetworkStatusFactory.createVerified().toSimple() + + runtimeStore.store( + value = mapOf(userWalletId.stringValue to setOf(status)), + ) + + store.storeError(userWalletId = userWalletId, network = network) + + val runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + status.copy(value = status.value.copySealed(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 network = MockCryptoCurrencyFactory().ethereum.network + + val networks = MockCryptoCurrencyFactory().ethereumAndStellar.mapTo(hashSetOf()) { it.network } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..df73b079bb --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreGetMethodTest.kt @@ -0,0 +1,83 @@ +package com.tangem.data.networks.store + +import com.google.common.truth.Truth +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.domain.network.MockNetworkStatusFactory +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.networks.models.SimpleNetworkStatus +import com.tangem.data.networks.toSimple +import com.tangem.datasource.local.datastore.RuntimeSharedStore +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 NetworksStatusesStoreGetMethodTest { + + private val runtimeStore = RuntimeSharedStore() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultNetworksStatusesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test get if runtime store is empty`() = runTest { + val actual = store.get(userWalletId = userWalletId) + + val values = backgroundScope.getEmittedValues(testScheduler, 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 = backgroundScope.getEmittedValues(testScheduler, actual) + + Truth.assertThat(values).isEqualTo(emptyList>()) + } + + @Test + fun `test get if runtime store contains portfolio with empty statuses`() = runTest { + runtimeStore.store( + value = mapOf(userWalletId.stringValue to emptySet()), + ) + + val actual = store.get(userWalletId = userWalletId) + + val values = backgroundScope.getEmittedValues(testScheduler, 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 status = MockNetworkStatusFactory.createVerified() + + runtimeStore.store( + value = mapOf(userWalletId.stringValue to setOf(status.toSimple())), + ) + + val actual = store.get(userWalletId = userWalletId) + + val values = backgroundScope.getEmittedValues(testScheduler, actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(setOf(status.toSimple()))) + } + + private companion object { + + val userWalletId = UserWalletId(stringValue = "011") + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..08314ee68e --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/store/NetworksStatusesStoreInitializationTest.kt @@ -0,0 +1,83 @@ +package com.tangem.data.networks.store + +import androidx.datastore.core.DataStore +import com.google.common.truth.Truth +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.domain.network.MockNetworkStatusFactory +import com.tangem.data.networks.models.SimpleNetworkStatus +import com.tangem.data.networks.toDataModel +import com.tangem.data.networks.toSimple +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +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 NetworksStatusesStoreInitializationTest { + + @Test + fun `test initialization if cache store is empty`() = runTest { + val runtimeStore = RuntimeSharedStore() + val persistenceStore: DataStore = mockk() + + every { persistenceStore.data } returns emptyFlow() + + DefaultNetworksStatusesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, // local mock + 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()) + + DefaultNetworksStatusesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = 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 status = MockNetworkStatusFactory.createVerified() + + persistenceStore.updateData { + it.toMutableMap().apply { + put(userWalletId.stringValue, setOf(status.toDataModel()!!)) + } + } + + DefaultNetworksStatusesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val expectedStatus = status.toSimple().copy(value = status.value.copySealed(source = StatusSource.CACHE)) + val expected = mapOf(userWalletId.stringValue to setOf(expectedStatus)) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected) + } + + private companion object { + val userWalletId = UserWalletId(stringValue = "011") + } +} \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/store/StoreActualNetworkStatusTest.kt b/data/networks/src/test/java/com/tangem/data/networks/store/StoreActualNetworkStatusTest.kt new file mode 100644 index 0000000000..0824241177 --- /dev/null +++ b/data/networks/src/test/java/com/tangem/data/networks/store/StoreActualNetworkStatusTest.kt @@ -0,0 +1,104 @@ +package com.tangem.data.networks.store + +import com.google.common.truth.Truth +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.domain.network.MockNetworkStatusFactory +import com.tangem.data.networks.models.SimpleNetworkStatus +import com.tangem.data.networks.toDataModel +import com.tangem.data.networks.toSimple +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.tokens.model.NetworkStatus +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 +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** +[REDACTED_AUTHOR] + */ +@RunWith(Parameterized::class) +internal class StoreActualNetworkStatusTest(private val model: Model) { + + private val runtimeStore = RuntimeSharedStore() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultNetworksStatusesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `store actual`() = runTest { + store.storeActual(userWalletId = userWalletId, value = model.status) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(model.runtimeExpected) + + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(model.persistenceExpected) + } + + data class Model( + val status: NetworkStatus, + val runtimeExpected: Map>, + val persistenceExpected: WalletIdWithStatusDM, + ) + + private companion object { + + val userWalletId = UserWalletId(stringValue = "011") + + @JvmStatic + @Parameterized.Parameters + fun data(): Collection { + return listOf( + Model( + status = MockNetworkStatusFactory.createVerified(), + runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + MockNetworkStatusFactory.createVerified().toSimple(), + ), + ), + persistenceExpected = mapOf( + userWalletId.stringValue to setOf( + MockNetworkStatusFactory.createVerified().toDataModel()!!, + ), + ), + ), + Model( + status = MockNetworkStatusFactory.createNoAccount(), + runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + MockNetworkStatusFactory.createNoAccount().toSimple(), + ), + ), + persistenceExpected = mapOf( + userWalletId.stringValue to setOf( + MockNetworkStatusFactory.createNoAccount().toDataModel()!!, + ), + ), + ), + Model( + status = MockNetworkStatusFactory.createMissedDerivation(), + runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + MockNetworkStatusFactory.createMissedDerivation().toSimple(), + ), + ), + persistenceExpected = mapOf(), + ), + Model( + status = MockNetworkStatusFactory.createUnreachable(), + runtimeExpected = mapOf( + userWalletId.stringValue to setOf( + MockNetworkStatusFactory.createUnreachable().toSimple(), + ), + ), + persistenceExpected = mapOf(), + ), + ) + } + } +} \ No newline at end of file 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 f0c4c72fea..197249ec85 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 @@ -1,11 +1,12 @@ package com.tangem.data.nft import arrow.core.Either +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.datasource.local.nft.NFTPersistenceStore import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory import com.tangem.datasource.local.nft.NFTRuntimeStore import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory -import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter @@ -20,11 +21,16 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn -import kotlinx.coroutines.* +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection +@Suppress("LargeClass") internal class DefaultNFTRepository @Inject constructor( private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory, private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory, @@ -32,84 +38,196 @@ internal class DefaultNFTRepository @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, ) : NFTRepository { - private val jobs = mutableMapOf() + private val networkJobs = ConcurrentHashMap() + private val collectionJobs = ConcurrentHashMap() - private val nftRuntimeStores = mutableMapOf() - private val nftPersistenceStores = mutableMapOf() - - private val nftSdkAssetConverter = NFTSdkAssetConverter( - nftSdkAssetIdentifierConverter = NFTSdkAssetIdentifierConverter, - nftSdkCollectionIdentifierConverter = NFTSdkCollectionIdentifierConverter, - ) - private val collectionConverter = NFTSdkCollectionConverter( - nftSdkCollectionIdentifierConverter = NFTSdkCollectionIdentifierConverter, - nftSdkAssetConverter = nftSdkAssetConverter, - ) + private val nftRuntimeStores = ConcurrentHashMap() + private val nftPersistenceStores = ConcurrentHashMap() override fun observeCollections(userWalletId: UserWalletId, networks: List): Flow> = - flow { - emitAll( - combine( - networks.map { getNFTRuntimeStore(it).getCollections() }, - ) { it.asList() }, - ) - }.onStart { - refreshCollections(userWalletId, networks) - } + flow { emitAll(observeCollectionsInternal(userWalletId, networks)) } + + private suspend fun observeCollectionsInternal( + userWalletId: UserWalletId, + networks: List, + ): Flow> = combine( + networks.map { observeCollectionsInternal(userWalletId, it) }, + ) { it.asList() } + + private suspend fun observeCollectionsInternal( + userWalletId: UserWalletId, + network: Network, + ): Flow = if (network.canHandleNFTs()) { + getNFTRuntimeStore(userWalletId, network).getCollections() + } else { + flowOf(NFTCollections.empty(network)) + } override suspend fun refreshCollections(userWalletId: UserWalletId, networks: List) = coroutineScope { - networks.map { network -> - launch(dispatchers.io) { - Either.catch { - expireCollections(network) - walletManagersFacade.getNFTCollections(userWalletId, network) - }.onLeft { - saveFailedStateInRuntime( - network = network, - error = it, - ) - }.onRight { - val mergedCollections = it.mergeWithStoredAssets(network) + networks.mapNotNull { network -> + if (network.canHandleNFTs()) { + launch(dispatchers.io) { + Either.catch { + expireCollections(userWalletId, network) - saveCollectionsInRuntime( - network = network, - collections = mergedCollections, - ) - saveCollectionsInPersistence( - network = network, - collections = mergedCollections, - ) - } - }.saveIn(getJobHolder(network)) + val collections = walletManagersFacade.getNFTCollections(userWalletId, network) + val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network) + + saveCollectionsInRuntime( + userWalletId = userWalletId, + network = network, + collections = mergedCollections, + ) + saveCollectionsInPersistence( + userWalletId = userWalletId, + network = network, + collections = mergedCollections, + ) + }.onLeft { + saveFailedStateInRuntime( + userWalletId = userWalletId, + network = network, + error = it, + ) + } + }.saveIn(getNetworkJobHolder(network)) + } else { + null + } }.joinAll() } - private suspend fun expireCollections(network: Network) { - val runtimeStore = getNFTRuntimeStore(network) + override suspend fun refreshAssets( + userWalletId: UserWalletId, + network: Network, + collectionId: NFTCollection.Identifier, + ) = coroutineScope { + launch(dispatchers.io) { + Either.catch { + expireAssets(userWalletId, network, collectionId) + val sdkCollectionId = NFTSdkCollectionIdentifierConverter.convertBack(collectionId) + + val assets = walletManagersFacade.getNFTAssets( + userWalletId = userWalletId, + network = network, + collectionIdentifier = sdkCollectionId, + ) + + assets.forEach { + val assetId = NFTSdkAssetIdentifierConverter.convert(it.identifier) + val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId) + if (price is NFTSalePrice.Error) { + refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier) + } + } + + getNFTPersistenceStore(userWalletId, network) + .getCollectionsSync() + ?.map { + if (it.identifier == sdkCollectionId) { + it.copy(assets = assets) + } else { + it + } + } + ?.let { + saveCollectionsInRuntime( + userWalletId = userWalletId, + network = network, + collections = it, + ) + saveCollectionsInPersistence( + userWalletId = userWalletId, + network = network, + collections = it, + ) + } + }.onLeft { + saveFailedStateInRuntime( + userWalletId = userWalletId, + network = network, + error = it, + ) + } + }.saveIn(getCollectionJobHolder(collectionId)).join() + } + + override suspend fun isNFTSupported(network: Network): Boolean = network.canHandleNFTs() + + private suspend fun refreshSalePrice( + userWalletId: UserWalletId, + network: Network, + sdkCollectionId: SdkNFTCollection.Identifier, + sdkAssetId: SdkNFTAsset.Identifier, + ) = coroutineScope { + launch(dispatchers.io) { + val assetId = NFTSdkAssetIdentifierConverter.convert(sdkAssetId) + + Either.catch { + saveSalePriceInRuntime(userWalletId, network, NFTSalePrice.Loading(assetId)) + + val sdkSalePrice = + walletManagersFacade.getNFTSalePrice(userWalletId, network, sdkCollectionId, sdkAssetId) + + val salePrice = if (sdkSalePrice == null) { + NFTSalePrice.Empty(assetId) + } else { + NFTSalePrice.Value( + assetId = assetId, + value = sdkSalePrice.value, + symbol = sdkSalePrice.symbol, + ) + } + + saveSalePriceInRuntime(userWalletId, network, salePrice) + + sdkSalePrice?.let { + saveSalePriceInPersistence(userWalletId, network, sdkAssetId, it) + } + }.onLeft { + saveSalePriceInRuntime(userWalletId, network, NFTSalePrice.Error(assetId)) + } + } + } + + private suspend fun expireCollections(userWalletId: UserWalletId, network: Network) { + val runtimeStore = getNFTRuntimeStore(userWalletId, network) val expiredCollections = runtimeStore .getCollectionsSync() - .let { collections -> - collections.copy( - content = when (val content = collections.content) { - is NFTCollections.Content.Collections -> content.copy( - source = StatusSource.CACHE, - ) - is NFTCollections.Content.Error -> content - }, + .changeStatusSource(StatusSource.CACHE) + runtimeStore.saveCollections(expiredCollections) + } + + private suspend fun expireAssets( + userWalletId: UserWalletId, + network: Network, + collectionId: NFTCollection.Identifier, + ) { + val runtimeStore = getNFTRuntimeStore(userWalletId, network) + val storedCollections = runtimeStore.getCollectionsSync() + val expiredCollections = storedCollections + .changeCollectionAssetsStatusSource(collectionId, StatusSource.CACHE) + .let { + storedCollections.copy( + content = it, ) } runtimeStore.saveCollections(expiredCollections) } - private suspend fun saveCollectionsInRuntime(network: Network, collections: List) { - getNFTRuntimeStore(network).saveCollections( + private suspend fun saveCollectionsInRuntime( + userWalletId: UserWalletId, + network: Network, + collections: List, + ) { + getNFTRuntimeStore(userWalletId, network).saveCollections( NFTCollections( network = network, content = NFTCollections.Content.Collections( collections = collections .map { collection -> - collectionConverter.convert(network to collection) + NFTSdkCollectionConverter.convert(network to collection) } .filter { it.id !is NFTCollection.Identifier.Unknown @@ -120,86 +238,169 @@ internal class DefaultNFTRepository @Inject constructor( ) } - private suspend fun saveFailedStateInRuntime(network: Network, error: Throwable) { - getNFTRuntimeStore(network).saveCollections( - NFTCollections( - network = network, - content = NFTCollections.Content.Error( - error = error, - ), - ), - ) + private suspend fun saveFailedStateInRuntime(userWalletId: UserWalletId, network: Network, error: Throwable) { + getNFTRuntimeStore(userWalletId, network).let { store -> + val storedCollections = store.getCollectionsSync() + val content = storedCollections.content + val updatedCollections = if (content is NFTCollections.Content.Collections && + !content.collections.isNullOrEmpty() + ) { + // if there is any cached collections in store, then mark them as not actual and emit anyway + storedCollections.changeStatusSource(StatusSource.ONLY_CACHE) + } else { + // otherwise, just emit an error + NFTCollections( + network = network, + content = NFTCollections.Content.Error(error), + ) + } + store.saveCollections(updatedCollections) + } } - private suspend fun saveCollectionsInPersistence(network: Network, collections: List) { - getNFTPersistenceStore(network).saveCollections(collections) + private suspend fun saveCollectionsInPersistence( + userWalletId: UserWalletId, + network: Network, + collections: List, + ) { + getNFTPersistenceStore(userWalletId, network).saveCollections(collections) } - private fun getJobHolder(network: Network): JobHolder = jobs[network] ?: run { + private suspend fun saveSalePriceInRuntime(userWalletId: UserWalletId, network: Network, salePrice: NFTSalePrice) { + getNFTRuntimeStore(userWalletId, network).saveSalePrice(salePrice) + } + + private suspend fun saveSalePriceInPersistence( + userWalletId: UserWalletId, + network: Network, + assetId: SdkNFTAsset.Identifier, + salePrice: SdkNFTAsset.SalePrice, + ) { + getNFTPersistenceStore(userWalletId, network).saveSalePrice(assetId, salePrice) + } + + private fun getNetworkJobHolder(network: Network): JobHolder = networkJobs.getOrPut(network) { JobHolder().also { - jobs[network] = it + networkJobs[network] = it } } - private fun getNFTPersistenceStore(network: Network): NFTPersistenceStore = nftPersistenceStores[network] ?: run { - nftPersistenceStoreFactory.provide(network).also { - nftPersistenceStores[network] = it + private fun getCollectionJobHolder(collectionId: NFTCollection.Identifier): JobHolder = + collectionJobs.getOrPut(collectionId) { + JobHolder().also { + collectionJobs[collectionId] = it + } + } + + private fun getNFTPersistenceStore(userWalletId: UserWalletId, network: Network): NFTPersistenceStore { + val storeId = (userWalletId to network).formatted() + return nftPersistenceStores.getOrPut(storeId) { + nftPersistenceStoreFactory.provide(userWalletId, network).also { + nftPersistenceStores[storeId] = it + } } } - private suspend fun getNFTRuntimeStore(network: Network): NFTRuntimeStore = nftRuntimeStores[network] ?: run { - nftRuntimeStoreFactory.provide(network).also { - nftRuntimeStores[network] = it - val storedCollections = getStoredCollections(network) - val storedPrices = getStoredPrices(network) - it.initialize( - collections = storedCollections, - prices = storedPrices, - ) + private suspend fun getNFTRuntimeStore(userWalletId: UserWalletId, network: Network): NFTRuntimeStore { + val storeId = (userWalletId to network).formatted() + return nftRuntimeStores.getOrPut(storeId) { + nftRuntimeStoreFactory.provide(network).also { + nftRuntimeStores[storeId] = it + val storedCollections = getStoredCollections(userWalletId, network) + val storedPrices = getStoredPrices(userWalletId, network) + it.initialize( + collections = storedCollections, + prices = storedPrices, + ) + } } } - private suspend fun getStoredCollections(network: Network) = getNFTPersistenceStore(network) - .getCollectionsSync() - .let { - NFTCollections( - network = network, - content = NFTCollections.Content.Collections( - collections = it - ?.map { collection -> - collectionConverter.convert(network to collection) - } - ?.filter { - it.id !is NFTCollection.Identifier.Unknown - }, - source = StatusSource.CACHE, - ), - ) - } - - private suspend fun getStoredPrices(network: Network) = getNFTPersistenceStore(network) - .getSalePricesSync() - .orEmpty() - .let { prices -> - prices - .mapKeys { - val (assetId, _) = it - NFTSdkAssetIdentifierConverter.convert(assetId) - } - .mapValues { - val (assetId, price) = it - NFTSalePrice.Value( - assetId = assetId, - value = price.value, - symbol = price.symbol, + private suspend fun getStoredCollections(userWalletId: UserWalletId, network: Network) = + getNFTPersistenceStore(userWalletId, network) + .getCollectionsSync() + .let { + NFTCollections( + network = network, + content = NFTCollections.Content.Collections( + collections = it + ?.map { collection -> + NFTSdkCollectionConverter.convert(network to collection) + } + ?.filter { + it.id !is NFTCollection.Identifier.Unknown + }, source = StatusSource.CACHE, - ) - } - } + ), + ) + } - private suspend fun List.mergeWithStoredAssets(network: Network): List { + private suspend fun getStoredPrices(userWalletId: UserWalletId, network: Network) = + getNFTPersistenceStore(userWalletId, network) + .getSalePricesSync() + .orEmpty() + .let { prices -> + prices + .mapKeys { + val (assetId, _) = it + NFTSdkAssetIdentifierConverter.convert(assetId) + } + .mapValues { + val (assetId, price) = it + NFTSalePrice.Value( + assetId = assetId, + value = price.value, + symbol = price.symbol, + ) + } + } + + private fun NFTCollections.changeStatusSource(source: StatusSource) = copy( + content = when (val content = content) { + is NFTCollections.Content.Collections -> content.copy( + source = source, + ) + is NFTCollections.Content.Error -> content + }, + ) + + private fun NFTCollections.changeCollectionAssetsStatusSource( + collectionId: NFTCollection.Identifier, + source: StatusSource, + ) = when (val content = content) { + is NFTCollections.Content.Collections -> + content + .copy( + collections = content + .collections + ?.map { + if (it.id == collectionId) { + it.changeAssetsStatusSource(source) + } else { + it + } + }, + ) + is NFTCollections.Content.Error -> content + } + + private fun NFTCollection.changeAssetsStatusSource(source: StatusSource) = copy( + assets = when (val assets = this.assets) { + is NFTCollection.Assets.Empty -> NFTCollection.Assets.Loading + is NFTCollection.Assets.Loading -> assets + is NFTCollection.Assets.Failed -> assets + is NFTCollection.Assets.Value -> assets.copy( + source = source, + ) + }, + ) + + private suspend fun List.mergeWithStoredAssets( + userWalletId: UserWalletId, + network: Network, + ): List { val storedCollections = - getNFTPersistenceStore(network) + getNFTPersistenceStore(userWalletId, network) .getCollectionsSync() .orEmpty() .associateBy(SdkNFTCollection::identifier) @@ -224,4 +425,11 @@ internal class DefaultNFTRepository @Inject constructor( } } } + + private fun Pair.formatted(): String { + val (walletId, network) = this + return walletId.stringValue + "_" + network.id.value + "_" + network.derivationPath.value + } + + private fun Network.canHandleNFTs(): Boolean = Blockchain.fromNetworkId(backendId)?.canHandleNFTs() == true } \ No newline at end of file diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultHotCryptoRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultHotCryptoRepository.kt index 86807b4905..dd1fb830b0 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultHotCryptoRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultHotCryptoRepository.kt @@ -131,7 +131,7 @@ internal class DefaultHotCryptoRepository( analyticsEventHandler.send( event = MainScreenAnalyticsEvent.HotTokenError( - errorCode = (it as? ApiResponseError.HttpException)?.code?.code?.toString().orEmpty(), + errorCode = (it as? ApiResponseError.HttpException)?.code?.numericCode?.toString().orEmpty(), ), ) } diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt b/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt index 91b4be5185..3ecf2df253 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/di/OnrampDataModule.kt @@ -8,6 +8,8 @@ import com.tangem.data.onramp.DefaultOnrampErrorResolver import com.tangem.data.onramp.DefaultOnrampRepository import com.tangem.data.onramp.DefaultOnrampTransactionRepository import com.tangem.data.onramp.converters.error.OnrampErrorConverter +import com.tangem.domain.onramp.repositories.LegacyTopUpRepository +import com.tangem.data.onramp.legacy.MercuryoTopUpRepository import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.response.ExpressErrorResponse import com.tangem.datasource.api.onramp.OnrampApi @@ -15,6 +17,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.crypto.DataSignatureVerifier import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.exchangeservice.hotcrypto.HotCryptoResponseStore +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.datasource.local.onramp.countries.OnrampCountriesStore import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore @@ -112,4 +115,16 @@ internal object OnrampDataModule { analyticsEventHandler = analyticsEventHandler, ) } + + @Provides + @Singleton + fun provideMercuryoRepository( + environmentConfigStorage: EnvironmentConfigStorage, + dispatchersProvider: CoroutineDispatcherProvider, + ): LegacyTopUpRepository { + return MercuryoTopUpRepository( + environmentConfigStorage = environmentConfigStorage, + dispatchersProvider = dispatchersProvider, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/mercuryo/MercuryoBlockchainMapping.kt b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt similarity index 96% rename from app/src/main/java/com/tangem/tap/network/exchangeServices/mercuryo/MercuryoBlockchainMapping.kt rename to data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt index 3c20cb9f8e..2291dbc4c6 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/mercuryo/MercuryoBlockchainMapping.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoBlockchainMapping.kt @@ -1,12 +1,13 @@ -package com.tangem.tap.network.exchangeServices.mercuryo +package com.tangem.data.onramp.legacy import com.tangem.blockchain.common.Blockchain /** + * Legacy onboarding note/twins to get top up URL from mercuryo * Map [Blockchain] to ids from [link](https://api.mercuryo.io/v1.6/lib/currencies) [crypto_currencies] */ @Suppress("CyclomaticComplexMethod") -internal val Blockchain.mercuryoNetwork: String? +public val Blockchain.mercuryoNetwork: String? get() { return when (this) { Blockchain.Algorand -> "ALGORAND" @@ -156,5 +157,6 @@ internal val Blockchain.mercuryoNetwork: String? Blockchain.Scroll, Blockchain.ScrollTestnet -> null Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet -> null Blockchain.KaspaTestnet -> null + Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> null } } \ No newline at end of file diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoTopUpRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoTopUpRepository.kt new file mode 100644 index 0000000000..91bd1a5854 --- /dev/null +++ b/data/onramp/src/main/java/com/tangem/data/onramp/legacy/MercuryoTopUpRepository.kt @@ -0,0 +1,47 @@ +package com.tangem.data.onramp.legacy + +import android.net.Uri +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.extensions.calculateSha512 +import com.tangem.common.extensions.toHexString +import com.tangem.datasource.local.config.environment.EnvironmentConfig +import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage +import com.tangem.domain.onramp.repositories.LegacyTopUpRepository +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class MercuryoTopUpRepository @Inject constructor( + private val environmentConfigStorage: EnvironmentConfigStorage, + private val dispatchersProvider: CoroutineDispatcherProvider, +) : LegacyTopUpRepository { + + override suspend fun getTopUpUrl(cryptoCurrency: CryptoCurrency, walletAddress: String): String = + withContext(dispatchersProvider.default) { + val blockchain = Blockchain.fromId(cryptoCurrency.network.id.value) + val environmentConfig = environmentConfigStorage.getConfigSync() + + val builder = Uri.Builder() + .scheme(LegacyTopUpRepository.SCHEME) + .authority("exchange.mercuryo.io") + .appendQueryParameter("widget_id", environmentConfig.mercuryoWidgetId) + .appendQueryParameter("type", "buy") + .appendQueryParameter("currency", cryptoCurrency.symbol) + .appendQueryParameter("address", walletAddress) + .appendQueryParameter("signature", environmentConfig.signature(walletAddress)) + .appendQueryParameter("fix_currency", "true") + .appendQueryParameter("redirect_url", LegacyTopUpRepository.SUCCESS_URL) + + blockchain.mercuryoNetwork?.let { + builder.appendQueryParameter("network", it) + } + + builder.build().toString() + } + + private fun EnvironmentConfig.signature(address: String) = (address + mercuryoSecret) + .calculateSha512() + .toHexString() + .lowercase() +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index d08938d3fe..198ecb2e5a 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -108,25 +108,19 @@ internal class DefaultStakingRepository( return integrationIdMap.getOrDefault(getIntegrationKey(cryptoCurrencyId), null) } - override suspend fun fetchEnabledYields(refresh: Boolean) { + override suspend fun fetchEnabledYields() { withContext(dispatchers.io) { - cacheRegistry.invokeOnExpire( - key = YIELDS_STORE_KEY, - skipCache = refresh, - block = { - when (val stakingTokensWithYields = stakeKitApi.getEnabledYields(preferredValidatorsOnly = false)) { - is ApiResponse.Success -> stakingYieldsStore.store( - stakingTokensWithYields.data.data.filter { - it.isAvailable ?: false - }, - ) - else -> { - stakingYieldsStore.store(emptyList()) - throw (stakingTokensWithYields as ApiResponse.Error).cause - } - } - }, - ) + when (val stakingTokensWithYields = stakeKitApi.getEnabledYields(preferredValidatorsOnly = false)) { + is ApiResponse.Success -> stakingYieldsStore.store( + stakingTokensWithYields.data.data.filter { + it.isAvailable ?: false + }, + ) + else -> { + stakingYieldsStore.store(emptyList()) + throw (stakingTokensWithYields as ApiResponse.Error).cause + } + } } } @@ -738,7 +732,7 @@ internal class DefaultStakingRepository( private companion object { const val YIELDS_STORE_KEY = "yields" - const val TON_INTEGRATION_ID = "ton-ton-tonwhales-pools-staking" + const val TON_INTEGRATION_ID = "ton-ton-chorus-one-pools-staking" const val SOLANA_INTEGRATION_ID = "solana-sol-native-multivalidator-staking" const val COSMOS_INTEGRATION_ID = "cosmos-atom-native-staking" const val ETHEREUM_POLYGON_INTEGRATION_ID = "ethereum-matic-native-staking" diff --git a/data/tokens/build.gradle.kts b/data/tokens/build.gradle.kts index 499154d785..8eb2673b05 100644 --- a/data/tokens/build.gradle.kts +++ b/data/tokens/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.domain.txhistory.models) implementation(projects.domain.wallets.models) implementation(projects.domain.staking.models) + implementation(projects.domain.staking) /** Project - Data */ implementation(projects.core.datasource) diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt index f72dea5b6d..b79cc3bc30 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt @@ -7,6 +7,7 @@ import com.tangem.blockchain.common.ReserveAmountProvider import com.tangem.blockchain.common.UtxoAmountLimitProvider import com.tangem.data.tokens.converters.UtxoConverter import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.utils.getTotalStakingBalance import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.CurrencyAmount @@ -18,6 +19,7 @@ import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.isZero +import com.tangem.utils.extensions.orZero import kotlinx.coroutines.withContext import java.math.BigDecimal @@ -132,8 +134,10 @@ internal class DefaultCurrencyChecksRepository( ): CryptoCurrencyWarning.Rent? { val rentData = walletManagersFacade.getRentInfo(userWalletId, currencyStatus.currency.network) ?: return null val balanceValue = currencyStatus.value as? CryptoCurrencyStatus.Loaded ?: return null - val stakingTotalBalance = - (balanceValue.yieldBalance as? YieldBalance.Data)?.getTotalStakingBalance() ?: BigDecimal.ZERO + val stakingBalance = balanceValue.yieldBalance as? YieldBalance.Data + val stakingTotalBalance = stakingBalance?.getTotalStakingBalance( + blockchainId = currencyStatus.currency.network.id.value, + ).orZero() return when { balanceValue.amount.isZero() && stakingTotalBalance.isZero() -> null balanceValue.amount < rentData.exemptionAmount && stakingTotalBalance.isZero() -> { diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt index 85fce53032..b85018c34b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultNetworksRepository.kt @@ -26,7 +26,9 @@ import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn import timber.log.Timber @Suppress("LongParameterList") @@ -60,19 +62,6 @@ internal class DefaultNetworksRepository( } } - override fun getNetworkStatusesUpdatesLegacy( - userWalletId: UserWalletId, - networks: Set, - ): Flow> = channelFlow { - networksStatusesStore.get(userWalletId) - .onEach(::send) - .launchIn(scope = this + dispatchers.io) - - withContext(dispatchers.io) { - fetchNetworksStatusesIfCacheExpired(userWalletId, networks, refresh = false) - } - } - override suspend fun fetchNetworkPendingTransactions(userWalletId: UserWalletId, networks: Set) { val currencies = getCurrencies(userWalletId, networks) withContext(dispatchers.io) { diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkStatusFactory.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkStatusFactory.kt index 3ccfb526a8..5f781ffb46 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkStatusFactory.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkStatusFactory.kt @@ -9,7 +9,7 @@ import com.tangem.domain.walletmanager.model.CryptoCurrencyTransaction import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult import timber.log.Timber -internal class NetworkStatusFactory { +class NetworkStatusFactory { fun createNetworkStatus( network: Network, diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 035040383b..f8999494d2 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.wallets.models) implementation(projects.domain.appCurrency.models) + implementation(projects.domain.tokens.models) /** Project - Utils */ implementation(projects.core.utils) diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt index 6a78abbf06..b513244dfc 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt @@ -43,11 +43,9 @@ internal class DefaultVisaRepository @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val visaApiRequestMaker: VisaApiRequestMaker, private val visaApi: TangemVisaApi, + private val visaCurrencyFactory: VisaCurrencyFactory, ) : VisaRepository { - private val currencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) { - VisaCurrencyFactory() - } private val txDetailsFactory by lazy(mode = LazyThreadSafetyMode.NONE) { VisaTxDetailsFactory() } @@ -79,20 +77,22 @@ internal class DefaultVisaRepository @Inject constructor( private suspend fun fetchVisaCurrency(userWalletId: UserWalletId, address: String) { val contractInfoProvider = visaLibLoader.getOrCreateProvider() + val paymentAccountAddress = getPaymentAccountAddress(userWalletId) + val userWallet = findVisaUserWallet(userWalletId) parZip( dispatchers.io, { contractInfoProvider.getContractInfo( walletAddress = address, - paymentAccountAddress = getPaymentAccountAddress(userWalletId), + paymentAccountAddress = paymentAccountAddress, ) }, { getFiatRate() }, { contractInfo, fiatRate -> fetchedCurrencies.update { value -> value.apply { - put(address, currencyFactory.create(contractInfo, fiatRate)) + put(address, visaCurrencyFactory.create(userWallet, contractInfo, fiatRate)) } } }, @@ -157,7 +157,7 @@ internal class DefaultVisaRepository @Inject constructor( ) } - // TODO select correct account when multiple accounts are available (will be implemented when backend is ready) + // There will be only one payment account (backend has filtered for us), so we can take the first one customerInfo.paymentAccounts.firstOrNull()?.paymentAccountAddress }.getOrNull() @@ -187,14 +187,14 @@ internal class DefaultVisaRepository @Inject constructor( } } - private suspend fun getFiatRate(): BigDecimal? { + private suspend fun getFiatRate(): BigDecimal { val fiatCurrencyId = VisaConstants.fiatCurrency.code.lowercase() val quotes = tangemTechApi.getQuotes( currencyId = fiatCurrencyId, coinIds = VisaConstants.TOKEN_ID, ).getOrThrow() - return quotes.quotes[VisaConstants.TOKEN_ID]?.price + return quotes.quotes[VisaConstants.TOKEN_ID]?.price ?: error("No price found") } private fun makeWalletAddresses(userWallet: UserWallet): Set
{ diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/MockVisaActivationRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/MockVisaActivationRepository.kt index ed49e8187b..5a33fb6a70 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/MockVisaActivationRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/MockVisaActivationRepository.kt @@ -14,7 +14,13 @@ class MockVisaActivationRepository @AssistedInject constructor( ) : VisaActivationRepository { override suspend fun getActivationRemoteState(): VisaActivationRemoteState { - return VisaActivationRemoteState.PaymentAccountDeploying + return VisaActivationRemoteState.CardWalletSignatureRequired( + request = VisaCardWalletDataToSignRequest( + orderId = "orderId", + customerId = "customerId", + customerWalletAddress = "customerWalletAddress", + ), + ) } override suspend fun getActivationRemoteStateLongPoll(): VisaActivationRemoteState { diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaCurrencyFactory.kt index 989a2d504c..7765fc8608 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaCurrencyFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaCurrencyFactory.kt @@ -1,16 +1,28 @@ package com.tangem.data.visa.utils +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.common.currency.getNetwork +import com.tangem.domain.common.util.derivationStyleProvider +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.NetworkAddress import com.tangem.domain.visa.model.VisaCurrency +import com.tangem.domain.wallets.models.UserWallet import com.tangem.lib.visa.model.VisaContractInfo import org.joda.time.DateTime import org.joda.time.DateTimeZone import org.joda.time.Instant import java.math.BigDecimal import java.math.BigInteger +import javax.inject.Inject -internal class VisaCurrencyFactory { +internal class VisaCurrencyFactory @Inject constructor( + private val cryptoCurrencyFactory: CryptoCurrencyFactory, + private val excludedBlockchains: ExcludedBlockchains, +) { - fun create(contractInfo: VisaContractInfo, fiatRate: BigDecimal?): VisaCurrency { + fun create(userWallet: UserWallet, contractInfo: VisaContractInfo, fiatRate: BigDecimal): VisaCurrency { val now = Instant.now() val currentLimit = if (contractInfo.limitsChangeDate > now) { contractInfo.oldLimits @@ -19,11 +31,30 @@ internal class VisaCurrencyFactory { } val remainingOtpLimit = getRemainingOtp(currentLimit, now) + val currencyNetwork = getNetwork( + blockchain = Blockchain.Polygon, + extraDerivationPath = null, + derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, + excludedBlockchains = excludedBlockchains, + canHandleTokens = true, + ) ?: error("Unable to create network for Visa currency") + + val cryptoCurrency = cryptoCurrencyFactory.createToken( + network = currencyNetwork, + rawId = CryptoCurrency.RawID(VisaConstants.TOKEN_ID), + name = contractInfo.token.name, + symbol = contractInfo.token.symbol, + decimals = contractInfo.token.decimals, + contractAddress = contractInfo.token.address, + ) + return VisaCurrency( + cryptoCurrency = cryptoCurrency, symbol = contractInfo.token.symbol, networkName = VisaConstants.NETWORK_NAME, decimals = contractInfo.token.decimals, fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, // [Second Visa Iteration] pass to create() params if needed fiatCurrency = VisaConstants.fiatCurrency, balances = with(contractInfo) { VisaCurrency.Balances( @@ -40,6 +71,12 @@ internal class VisaCurrencyFactory { singleTransaction = currentLimit.singleTransactionLimit, expirationDate = getLimitsExpirationDate(currentLimit, now), ), + paymentAccountAddress = NetworkAddress.Single( + NetworkAddress.Address( + value = contractInfo.paymentAccountAddress, + type = NetworkAddress.Address.Type.Primary, + ), + ), ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt index c69adf5664..8f9b41148f 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt @@ -13,7 +13,6 @@ internal class VisaTxDetailsFactory { type = transaction.transactionType, status = transaction.transactionStatus, blockchainAmount = transaction.blockchainAmount, - blockchainFee = transaction.blockchainFee, transactionAmount = transaction.transactionAmount, transactionCurrencyCode = transaction.transactionCurrencyCode, merchantName = transaction.merchantName, @@ -33,7 +32,6 @@ internal class VisaTxDetailsFactory { billingAmount = request.billingAmount, billingCurrencyCode = request.billingCurrencyCode, blockchainAmount = request.blockchainAmount, - blockchainFee = request.blockchainFee, errorCode = request.errorCode, requestDate = request.requestDt, requestStatus = request.requestStatus, diff --git a/data/wallet-connect/build.gradle.kts b/data/wallet-connect/build.gradle.kts index 5dc33697df..159348516a 100644 --- a/data/wallet-connect/build.gradle.kts +++ b/data/wallet-connect/build.gradle.kts @@ -13,7 +13,15 @@ dependencies { /* Project - Domain */ implementation(projects.domain.walletConnect) + implementation(projects.domain.walletConnect.models) + implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.models) + implementation(projects.domain.legacy) + implementation(projects.libs.blockchainSdk) + implementation(projects.data.common) /* Project - Data */ implementation(projects.core.datasource) @@ -25,6 +33,18 @@ dependencies { implementation(deps.hilt.core) kapt(deps.hilt.kapt) + /* Tangem libraries */ + implementation(tangemDeps.blockchain) + + /* Reown - WalletConnect */ + implementation(deps.reownCore) { + exclude(group = "app.cash.sqldelight", module = "android-driver") + } + implementation(deps.reownWeb3) { + exclude(group = "app.cash.sqldelight", module = "android-driver") + } + /* Other */ implementation(deps.kotlin.coroutines) + implementation(deps.arrow.core) } \ 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 02c6f391db..edab0d12af 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 @@ -1,13 +1,42 @@ package com.tangem.data.walletconnect.di +import android.app.Application +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.request.DefaultWcRequestService +import com.tangem.data.walletconnect.request.WcMethodHandler +import com.tangem.data.walletconnect.respond.DefaultWcRespondService +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 +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -22,4 +51,133 @@ internal object WalletConnectDataModule { ): WalletConnectRepository { return DefaultWalletConnectRepository(userWalletsStore, dispatchers) } + + @Provides + @Singleton + fun wcInitializeUseCase( + application: Application, + sessionsManager: DefaultWcSessionsManager, + networkService: DefaultWcRequestService, + wcPairFlow: DefaultWcPairUseCase, + ): WcInitializeUseCase = DefaultWcInitializeUseCase( + application = application, + sessionsManager = sessionsManager, + networkService = networkService, + wcPairFlow = wcPairFlow, + ) + + @Provides + @Singleton + fun defaultWcPairUseCase( + sessionsManager: WcSessionsManager, + associateNetworksDelegate: AssociateNetworksDelegate, + caipNamespaceDelegate: CaipNamespaceDelegate, + ): DefaultWcPairUseCase = DefaultWcPairUseCase( + sessionsManager = sessionsManager, + associateNetworksDelegate = associateNetworksDelegate, + caipNamespaceDelegate = caipNamespaceDelegate, + ) + + @Provides + @Singleton + fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default + + @Provides + @Singleton + fun defaultWcSessionsManager( + store: WalletConnectStore, + dispatchers: CoroutineDispatcherProvider, + legacyStore: WalletConnectSessionsRepository, + getWallets: GetWalletsUseCase, + ): DefaultWcSessionsManager { + val scope = CoroutineScope(SupervisorJob() + dispatchers.io) + return DefaultWcSessionsManager( + store = store, + dispatchers = dispatchers, + legacyStore = legacyStore, + getWallets = getWallets, + scope = scope, + ) + } + + @Provides + @Singleton + fun wcSessionsManager(default: DefaultWcSessionsManager): WcSessionsManager = default + + @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, + 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( + moshi = moshi, + respondService = respondService, + ) + + @Provides + @Singleton + fun wcSolanaNetwork(): WcSolanaNetwork = WcSolanaNetwork() + + @Provides + @Singleton + fun caipNamespaceDelegate( + diHelperBox: DiHelperBox, + walletManagersFacade: WalletManagersFacade, + ): CaipNamespaceDelegate = CaipNamespaceDelegate( + namespaceConverters = diHelperBox.converters, + walletManagersFacade = walletManagersFacade, + ) + + @Provides + @Singleton + fun associateNetworksDelegate( + diHelperBox: DiHelperBox, + getWallets: GetWalletsUseCase, + currenciesRepository: CurrenciesRepository, + excludedBlockchains: ExcludedBlockchains, + ): AssociateNetworksDelegate = AssociateNetworksDelegate( + namespaceConverters = diHelperBox.converters, + getWallets = getWallets, + currenciesRepository = currenciesRepository, + excludedBlockchains = excludedBlockchains, + ) + + @Provides + @Singleton + fun diHelperBox(ethNetwork: WcEthNetwork, solanaNetwork: WcSolanaNetwork) = DiHelperBox( + handlers = setOf( + ethNetwork, + ), + converters = buildMap { + ethNetwork.namespaceKey to ethNetwork + solanaNetwork.namespaceKey to solanaNetwork + }, + ) + + internal class DiHelperBox( + val converters: Map, + 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 new file mode 100644 index 0000000000..6330859a84 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt @@ -0,0 +1,116 @@ +package com.tangem.data.walletconnect.initialize + +import android.app.Application +import com.reown.android.Core +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.request.DefaultWcRequestService +import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager +import com.tangem.data.walletconnect.utils.WcSdkObserver +import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase +import timber.log.Timber + +internal class DefaultWcInitializeUseCase( + private val application: Application, + private val sessionsManager: DefaultWcSessionsManager, + private val networkService: DefaultWcRequestService, + private val wcPairFlow: DefaultWcPairUseCase, +) : WcInitializeUseCase { + + private val wcSdkObservers = mutableSetOf( + sessionsManager, + networkService, + wcPairFlow, + ) + + override fun init(projectId: String) { + val relayUrl = "relay.walletconnect.com" + val serverUrl = "wss://$relayUrl?projectId=$projectId" + val connectionType = ConnectionType.AUTOMATIC + + val appMetaData = Core.Model.AppMetaData( + name = "Tangem", + description = "Tangem Wallet", + url = "tangem.com", + icons = listOf( + "https://user-images.githubusercontent.com/24321494/124071202-72a00900-da58-11eb-935a-dcdab21de52b.png", + ), + redirect = "kotlin-wallet-wc:/request", // Custom Redirect URI + ) + + CoreClient.initialize( + relayServerUrl = serverUrl, + connectionType = connectionType, + application = application, + metaData = appMetaData, + ) { error -> + Timber.e("Error while initializing client: $error") + } + + WalletKit.initialize( + Wallet.Params.Init(core = CoreClient), + onSuccess = { + val walletDelegate = defineWalletDelegate() + WalletKit.setWalletDelegate(walletDelegate) + wcSdkObservers.forEach { it.onWcSdkInit() } + }, + onError = { error -> + Timber.e("Error while initializing Web3Wallet: $error") + }, + ) + } + + private fun defineWalletDelegate() = object : WalletKit.WalletDelegate { + override val onSessionAuthenticate: ((Wallet.Model.SessionAuthenticate, Wallet.Model.VerifyContext) -> Unit)? + get() = super.onSessionAuthenticate + + override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) { + wcSdkObservers.forEach { it.onConnectionStateChange(state) } + } + + override fun onError(error: Wallet.Model.Error) { + wcSdkObservers.forEach { it.onError(error) } + } + + override fun onProposalExpired(proposal: Wallet.Model.ExpiredProposal) { + wcSdkObservers.forEach { it.onProposalExpired(proposal) } + } + + override fun onRequestExpired(request: Wallet.Model.ExpiredRequest) { + wcSdkObservers.forEach { it.onRequestExpired(request) } + } + + override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { + wcSdkObservers.forEach { it.onSessionDelete(sessionDelete) } + } + + override fun onSessionExtend(session: Wallet.Model.Session) { + wcSdkObservers.forEach { it.onSessionExtend(session) } + } + + override fun onSessionProposal( + sessionProposal: Wallet.Model.SessionProposal, + verifyContext: Wallet.Model.VerifyContext, + ) { + wcSdkObservers.forEach { it.onSessionProposal(sessionProposal, verifyContext) } + } + + override fun onSessionRequest( + sessionRequest: Wallet.Model.SessionRequest, + verifyContext: Wallet.Model.VerifyContext, + ) { + wcSdkObservers.forEach { it.onSessionRequest(sessionRequest, verifyContext) } + } + + override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { + wcSdkObservers.forEach { it.onSessionSettleResponse(settleSessionResponse) } + } + + override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) { + wcSdkObservers.forEach { it.onSessionUpdateResponse(sessionUpdateResponse) } + } + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/CAIP10.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/CAIP10.kt new file mode 100644 index 0000000000..c338a4d343 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/CAIP10.kt @@ -0,0 +1,40 @@ +package com.tangem.data.walletconnect.model + +import com.tangem.data.walletconnect.model.CAIP2.Companion.CAIP_SEPARATOR + +/** + * CAIP-10 defines a way to identify an account in any blockchain specified by [CAIP2] blockchain id. + * + * Syntax + * account_id: chain_id + ":" + account_address + * chain_id: [-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32} @see[CAIP2] + * account_address: [-.%a-zA-Z0-9]{1,128} + * + * Examples + * # Ethereum mainnet + * eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb + * + * # Bitcoin mainnet + * bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6 + * + * For more information https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-10.md + */ +internal data class CAIP10 constructor( + val chainId: CAIP2, + val accountAddress: String, +) { + val raw get() = "${chainId.raw}$CAIP_SEPARATOR$accountAddress" + + companion object { + private const val CAIP10_SUBSTRINGS_SIZE = 3 + fun fromRaw(s: String): CAIP10? { + val parsed = s.split(CAIP_SEPARATOR) + if (parsed.size != CAIP10_SUBSTRINGS_SIZE) return null + val namespace = parsed[0] + val reference = parsed[1] + val accountAddress = parsed[2] + val chainId = CAIP2.fromRaw(namespace + CAIP_SEPARATOR + reference) ?: return null + return CAIP10(chainId, accountAddress) + } + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/CAIP2.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/CAIP2.kt new file mode 100644 index 0000000000..fdd24f1ddd --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/CAIP2.kt @@ -0,0 +1,36 @@ +package com.tangem.data.walletconnect.model + +/** + * CAIP-2 defines a way to identify a blockchain (e.g. Ethereum Mainnet, Görli, Bitcoin, Cosmos Hub) + * in a human-readable, developer-friendly and transaction-friendly way. + * + * Syntax + * chain_id: namespace + ":" + reference + * namespace: [-a-z0-9]{3,8} + * reference: [-_a-zA-Z0-9]{1,32} + * + * Examples + * # Ethereum mainnet + * eip155:1 + * + * # Bitcoin mainnet + * bip122:000000000019d6689c085ae165831e93 + * + * For more information https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md + */ +internal data class CAIP2 constructor( + val namespace: String, + val reference: String, +) { + val raw get() = "$namespace$CAIP_SEPARATOR$reference" + + companion object { + const val CAIP_SEPARATOR = ":" + + fun fromRaw(s: String): CAIP2? { + val parsed = s.split(CAIP_SEPARATOR) + if (parsed.size != 2) return null + return CAIP2(parsed[0], parsed[1]) + } + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/NamespaceKey.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/NamespaceKey.kt new file mode 100644 index 0000000000..d212088f0c --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/model/NamespaceKey.kt @@ -0,0 +1,4 @@ +package com.tangem.data.walletconnect.model + +@JvmInline +internal value class NamespaceKey(val key: String) \ 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 new file mode 100644 index 0000000000..69407681e0 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt @@ -0,0 +1,77 @@ +package com.tangem.data.walletconnect.network.ethereum + +import com.squareup.moshi.Moshi +import com.tangem.blockchain.common.Blockchain +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.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 + +internal class WcEthNetwork( + private val moshi: Moshi, + private val respondService: WcRespondService, +) : WcMethodHandler, WcUseCasesFlowProvider, WcNamespaceConverter { + + private val _useCases: Channel = Channel(Channel.BUFFERED) + override val useCases = _useCases.receiveAsFlow() + + override val namespaceKey: NamespaceKey = NamespaceKey("eip155") + + override fun canHandle(methodName: String): Boolean { + return Name.entries.find { it.raw == methodName } != null + } + + 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) + Name.SignTypeData -> TODO() + Name.SignTypeDataV4 -> TODO() + Name.SignTransaction -> TODO() + Name.SendTransaction -> TODO() + } + } + + 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"), + PersonalSign("personal_sign"), + SignTypeData("eth_signTypedData"), + SignTypeDataV4("eth_signTypedData_v4"), + SignTransaction("eth_signTransaction"), + SendTransaction("eth_sendTransaction"), + } + + 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? { + if (!blockchain.isEvm()) return null + val chainId = blockchain.getChainId() ?: return null + return CAIP2( + namespace = namespaceKey.key, + reference = chainId.toString(), + ) + } +} \ 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 new file mode 100644 index 0000000000..bda63dde43 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaNetwork.kt @@ -0,0 +1,37 @@ +package com.tangem.data.walletconnect.network.solana + +import com.tangem.blockchain.common.Blockchain +import com.tangem.data.walletconnect.model.CAIP2 +import com.tangem.data.walletconnect.model.NamespaceKey +import com.tangem.data.walletconnect.utils.WcNamespaceConverter + +internal class WcSolanaNetwork : WcNamespaceConverter { + override val namespaceKey: NamespaceKey = NamespaceKey("solana") + + override fun toBlockchain(chainId: CAIP2): Blockchain? { + if (chainId.namespace != namespaceKey.key) return null + return when (chainId.reference) { + MAINNET_CHAIN_ID -> Blockchain.Solana + TESTNET_CHAIN_ID -> Blockchain.SolanaTestnet + else -> null + } + } + + override fun toCAIP2(blockchain: Blockchain): CAIP2? { + val chainId = when (blockchain) { + Blockchain.Solana -> MAINNET_CHAIN_ID + Blockchain.SolanaTestnet -> TESTNET_CHAIN_ID + else -> null + } + chainId ?: return null + return CAIP2( + namespace = namespaceKey.key, + reference = chainId, + ) + } + + companion object { + private const val MAINNET_CHAIN_ID = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + private const val TESTNET_CHAIN_ID = "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z" + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..e24b2641ac --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt @@ -0,0 +1,103 @@ +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.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 getWallets: GetWalletsUseCase, + private val currenciesRepository: CurrenciesRepository, + private val excludedBlockchains: ExcludedBlockchains, +) { + + @Throws(WcPairError.UnsupportedNetworks::class) + 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() + + return userWallets.associate { wallet -> + wallet.walletId to mapNetworksForWallet(wallet, requiredNamespaces, optionalNamespaces) + } + } + + private suspend fun mapNetworksForWallet( + wallet: UserWallet, + 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, + ) + + requiredNamespaces.forEach { chainId -> + val blockchain = chainId.toBlockchain() + if (blockchain == null) { + unknownRequired.add(WcNetwork.Unknown(missingNetworkName(chainId))) + return@forEach + } + val wcNetwork = blockchain.toNetwork() + if (wcNetwork == null) { + unknownRequired.add(WcNetwork.Unknown(missingNetworkName(blockchain))) + return@forEach + } + val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id } + if (walletNetwork == null) { + missingRequired.add(WcNetwork.Supported(wcNetwork)) + } else { + required.add(WcNetwork.Supported(walletNetwork)) + } + } + optionalNamespaces.forEach { chainId -> + val wcNetwork = chainId.toBlockchain()?.toNetwork() ?: return@forEach + val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id } + if (walletNetwork == null) { + available.add(WcNetwork.Supported(wcNetwork)) + } else { + notAdded.add(WcNetwork.Supported(walletNetwork)) + } + } + if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedNetworks(unknownRequired) + return ProposalNetwork( + walletId = wallet.walletId, + missingRequired = missingRequired, + required = required, + available = available, + notAdded = notAdded, + ) + } + + private fun Map.setOfChainId(): Set = + this.values.flatMap { proposal -> proposal.chains ?: listOf() } + .mapNotNull { rawChainId -> CAIP2.fromRaw(rawChainId) }.toSet() + + private fun missingNetworkName(chainId: CAIP2): String = chainId.namespace.replaceFirstChar(Char::titlecase) + private fun missingNetworkName(blockchain: Blockchain): String = blockchain.getCoinName() +} \ 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 new file mode 100644 index 0000000000..98e5e6be32 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/CaipNamespaceDelegate.kt @@ -0,0 +1,62 @@ +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 + +internal class CaipNamespaceDelegate constructor( + private val namespaceConverters: Map, + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend fun associate( + sessionProposal: Wallet.Model.SessionProposal, + userWalletId: UserWalletId, + 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) + requireNotNull(chainId) + requireNotNull(address) + CAIP10(chainId = chainId, accountAddress = address) + }.forEach { account -> + val namespaceKey = account.chainId.namespace + val session = result.getOrPut(namespaceKey) { Session() } + val requiredNamespaces = sessionProposal.requiredNamespaces + val optionalNamespaces = sessionProposal.optionalNamespaces + val methods = buildSet { + requiredNamespaces[namespaceKey]?.methods?.let { addAll(it) } + optionalNamespaces[namespaceKey]?.methods?.let { addAll(it) } + } + session.chains.add(account.chainId.raw) + session.accounts.add(account.raw) + session.methods.addAll(methods) + } + return result.mapValues { (namespaceKey, session) -> + Wallet.Model.Namespace.Session( + chains = session.chains.toList(), + accounts = session.accounts.toList(), + methods = session.methods.toList(), + events = session.events.toList(), + ) + } + } + + private data class Session( + val chains: MutableSet = mutableSetOf(), + val accounts: MutableSet = mutableSetOf(), + val methods: MutableSet = mutableSetOf(), + val events: MutableSet = mutableSetOf(), + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..65f7b110ee --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -0,0 +1,221 @@ +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.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.model.WcSessionProposal +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 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") + +internal class DefaultWcPairUseCase( + private val sessionsManager: WcSessionsManager, + private val associateNetworksDelegate: AssociateNetworksDelegate, + private val caipNamespaceDelegate: CaipNamespaceDelegate, +) : WcPairUseCase, WcSdkObserver { + + 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? + } + + // check unsupported dApps, just local constant for now, finish if unsupported + if (sdkSessionProposal.name in unsupportedDApps) { + Timber.i("Unsupported DApp") + val error = WcPairState.Error(WcPairError.UnsupportedDApp) + emit(error) + return@flow + } + + val proposalState = buildProposalState(sdkSessionProposal) + .fold(ifLeft = { WcPairState.Error(it) }, ifRight = { it }) + 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 + } + } + // finish flow if rejected above + sessionForApprove ?: 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() + } + } + }, + ) + emit(WcPairState.Approving.Result(sessionForApprove, either)) + } + } + + override fun approve(sessionForApprove: WcSessionApprove) { + onCallTerminalAction.trySend(TerminalAction.Approve(sessionForApprove)) + } + + override fun reject() { + 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 { + val namespaces = caipNamespaceDelegate.associate( + sdkSessionProposal, + sessionForApprove.walletId, + sessionForApprove.network.map { it.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") + }, + ) + } + + private suspend fun buildProposalState( + sessionProposal: Wallet.Model.SessionProposal, + ): Either = runCatching { + val proposalNetwork = associateNetworksDelegate.associate(sessionProposal) + val appMetaData = WcAppMetaData( + name = sessionProposal.name, + description = sessionProposal.description, + url = sessionProposal.url, + icons = sessionProposal.icons.map { it.toString() }, + redirect = sessionProposal.redirect, + ) + val dAppSession = WcSessionProposal( + dAppMetaData = appMetaData, + proposalNetwork = proposalNetwork, + securityStatus = Any(), + ) + WcPairState.Proposal(dAppSession) + }.fold(onSuccess = { it.right() }, onFailure = { + when (it) { + is WcPairError -> it.left() + else -> WcPairError.Unknown(it.localizedMessage.orEmpty()).left() + } + },) + + private fun Wallet.Model.Session.toDomain(walletId: UserWalletId): WcSession = WcSession( + userWalletId = walletId, + sdkModel = WcSdkSessionConverter.convert(this), + ) + + private sealed interface TerminalAction { + data class Approve(val sessionForApprove: WcSessionApprove) : TerminalAction + data object Reject : TerminalAction + } +} \ 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 new file mode 100644 index 0000000000..99f06b1f7a --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt @@ -0,0 +1,47 @@ +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +internal class DefaultWcRequestService( + private val sessionsManager: WcSessionsManager, + private val respondService: WcRespondService, + private val requestAdapters: Set>, + private val scope: CoroutineScope, +) : WcRequestService, WcSdkObserver { + + override val requests: MutableSharedFlow> = MutableSharedFlow() + + override fun onSessionRequest( + sessionRequest: Wallet.Model.SessionRequest, + verifyContext: Wallet.Model.VerifyContext, + ) { + // 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) + } + } +} \ 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 new file mode 100644 index 0000000000..b31b707d58 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/WcMethodHandler.kt @@ -0,0 +1,18 @@ +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/respond/DefaultWcRespondService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt new file mode 100644 index 0000000000..7381e9b548 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt @@ -0,0 +1,53 @@ +package com.tangem.data.walletconnect.respond + +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.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.respond.WcRespondService +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +internal class DefaultWcRespondService : WcRespondService { + + override suspend fun respond(request: WcSdkSessionRequest, response: String): Either = + suspendCancellableCoroutine { continuation -> + WalletKit.respondSessionRequest( + params = Wallet.Params.SessionRequestResponse( + sessionTopic = request.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcResult( + id = request.request.id, + result = response, + ), + ), + onSuccess = { + continuation.resume(Unit.right()) + }, + onError = { + continuation.resume(it.throwable.left()) + }, + ) + } + + override suspend fun rejectRequest(request: WcSdkSessionRequest, message: String) = + suspendCancellableCoroutine { continuation -> + WalletKit.respondSessionRequest( + params = Wallet.Params.SessionRequestResponse( + sessionTopic = request.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( + id = request.request.id, + code = 0, + message = message, + ), + ), + onSuccess = { + continuation.resume(Unit.right()) + }, + onError = { + continuation.resume(it.throwable.left()) + }, + ) + } +} \ 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 new file mode 100644 index 0000000000..90ccec710d --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -0,0 +1,151 @@ +package com.tangem.data.walletconnect.sessions + +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.datasource.local.walletconnect.WalletConnectStore +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.usecase.GetWalletsUseCase +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.seconds + +internal class DefaultWcSessionsManager constructor( + private val store: WalletConnectStore, + private val legacyStore: WalletConnectSessionsRepository, + private val getWallets: GetWalletsUseCase, + private val dispatchers: CoroutineDispatcherProvider, + private val scope: CoroutineScope, +) : WcSessionsManager, WcSdkObserver { + + private val onSessionDelete = Channel(capacity = Channel.BUFFERED) + private val oneTimeMigration = MutableStateFlow(true) + + override val sessions: Flow>> + get() = store.sessions + .transform { inStore -> + if (oneTimeMigration.value) { + oneTimeMigration.value = false + val someMigrated = migrateLegacyStore(inStore) + if (someMigrated) return@transform // ignore emit, wait next one + } + val inSdk: List = WalletKit.getListOfActiveSessions() + val associatedSessions: List = associateWithSdk(inSdk, inStore) + val someRemove = removeUnknownSessions(inStore, associatedSessions) + if (someRemove) return@transform // ignore emit, wait next one + emit(associatedSessions.groupBy { it.userWalletId }) + } + .flowOn(dispatchers.io) + + override fun onWcSdkInit() { + oneTimeMigration.value = true + listenOnSessionDelete() + } + + override suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) { + store.saveSession(WcSessionDTO(session.sdkModel.topic, session.userWalletId)) + } + + override suspend fun removeSession(userWalletId: UserWalletId, session: WcSession): Either { + val topic = session.sdkModel.topic + val sdkCall = sdkDisconnectSession(topic) + sdkCall.onLeft { return it.left() } + suspend fun waitSdkCallback() = onSessionDelete.receiveAsFlow().first { + val isSomeError = it is Wallet.Model.SessionDelete.Error + val isDeleted = it is Wallet.Model.SessionDelete.Success && it.topic == topic + isSomeError || isDeleted + } + + val waitSdkCallback = runCatching { withTimeout(10.seconds) { waitSdkCallback() } } + val sdkCallback = waitSdkCallback.getOrElse { return it.left() } + return when (sdkCallback) { + is Wallet.Model.SessionDelete.Error -> sdkCallback.error.left() + is Wallet.Model.SessionDelete.Success -> Unit.right() + } + } + + override suspend fun findSessionByTopic(topic: String): WcSession? = withContext(dispatchers.io) { + val storedSessions = store.findSessionByTopic(topic) ?: return@withContext null + val sdkSession = WalletKit.getActiveSessionByTopic(topic) ?: return@withContext null + WcSession(userWalletId = storedSessions.walletId, sdkModel = WcSdkSessionConverter.convert(sdkSession)) + } + + override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { + Timber.i("onSessionDelete: $sessionDelete") + onSessionDelete.trySend(sessionDelete) + } + + private suspend fun migrateLegacyStore(inNewStoreSessions: Set): Boolean { + val walletIds = getWallets.invokeSync().mapTo(mutableSetOf()) { it.walletId } + val inLegacyStoreSessions = walletIds + .map { walletId -> + flow { emit(legacyStore.loadSessions(walletId.stringValue).map { WcSessionDTO(it.topic, walletId) }) } + } + .merge() + .reduce { accumulator, value -> accumulator.plus(value) } + + val mustSaveInNewStore = inLegacyStoreSessions.subtract(inNewStoreSessions) + if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore) + return mustSaveInNewStore.isNotEmpty() + } + + private fun associateWithSdk( + sdkSessions: List, + storeSessions: Set, + ): 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)) + } + return wcSessions + } + + private suspend fun removeUnknownSessions(storeSessions: Set, wcSessions: List): Boolean { + val unknownStoredSessions = storeSessions + .filterNot { dto -> wcSessions.any { it.sdkModel.topic == dto.topic } } + val haveSomeUnknown = unknownStoredSessions.isNotEmpty() + + if (haveSomeUnknown) { + unknownStoredSessions.forEach { unknown -> + legacyStore.removeSession(unknown.walletId.stringValue, unknown.topic) + } + store.removeSessions(unknownStoredSessions.toSet()) + } + return haveSomeUnknown + } + + private suspend fun sdkDisconnectSession(topic: String): Either { + return suspendCancellableCoroutine { continuation -> + WalletKit.disconnectSession( + params = Wallet.Params.SessionDisconnect(topic), + onSuccess = { continuation.resume(Unit.right()) }, + onError = { continuation.resume(it.throwable.left()) }, + ) + } + } + + private fun listenOnSessionDelete() { + onSessionDelete.receiveAsFlow() + .filterIsInstance() + .mapNotNull { sessionDelete -> store.findSessionByTopic(sessionDelete.topic) } + .onEach { sessionDto -> store.removeSession(sessionDto) } + .flowOn(dispatchers.io) + .launchIn(scope) + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/JSONRPCRequestConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/JSONRPCRequestConverter.kt new file mode 100644 index 0000000000..f882b3c3a5 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/JSONRPCRequestConverter.kt @@ -0,0 +1,15 @@ +package com.tangem.data.walletconnect.utils + +import com.reown.walletkit.client.Wallet +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest.JSONRPCRequest +import com.tangem.utils.converter.Converter + +internal object JSONRPCRequestConverter : Converter { + override fun convert(value: Wallet.Model.SessionRequest.JSONRPCRequest): JSONRPCRequest { + return JSONRPCRequest( + id = value.id, + method = value.method, + params = value.params, + ) + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcAppMetaDataConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcAppMetaDataConverter.kt new file mode 100644 index 0000000000..df4fdcaade --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcAppMetaDataConverter.kt @@ -0,0 +1,33 @@ +package com.tangem.data.walletconnect.utils + +import com.reown.android.Core +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData +import com.tangem.utils.converter.Converter + +internal object WcAppMetaDataConverter : Converter { + + val empty + get() = WcAppMetaData( + name = "", + description = "", + url = "", + icons = listOf(), + redirect = null, + appLink = null, + linkMode = false, + verifyUrl = null, + ) + + override fun convert(value: Core.Model.AppMetaData): WcAppMetaData { + return WcAppMetaData( + name = value.name, + description = value.description, + url = value.url, + icons = value.icons, + redirect = value.redirect, + appLink = value.appLink, + linkMode = value.linkMode, + verifyUrl = value.verifyUrl, + ) + } +} \ 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 new file mode 100644 index 0000000000..bb1e86298b --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNamespaceConverter.kt @@ -0,0 +1,12 @@ +package com.tangem.data.walletconnect.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.data.walletconnect.model.CAIP2 +import com.tangem.data.walletconnect.model.NamespaceKey + +internal interface WcNamespaceConverter { + + val namespaceKey: NamespaceKey + fun toBlockchain(chainId: CAIP2): Blockchain? + fun toCAIP2(blockchain: Blockchain): CAIP2? +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt new file mode 100644 index 0000000000..4c70570c4d --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt @@ -0,0 +1,40 @@ +package com.tangem.data.walletconnect.utils + +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit + +internal interface WcSdkObserver : WalletKit.WalletDelegate { + + override val onSessionAuthenticate: ((Wallet.Model.SessionAuthenticate, Wallet.Model.VerifyContext) -> Unit)? + get() = super.onSessionAuthenticate + + fun onWcSdkInit() {} + + override fun onConnectionStateChange(state: Wallet.Model.ConnectionState) {} + + override fun onError(error: Wallet.Model.Error) {} + + override fun onProposalExpired(proposal: Wallet.Model.ExpiredProposal) {} + + override fun onRequestExpired(request: Wallet.Model.ExpiredRequest) {} + + override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) {} + + override fun onSessionExtend(session: Wallet.Model.Session) {} + + override fun onSessionProposal( + sessionProposal: Wallet.Model.SessionProposal, + verifyContext: Wallet.Model.VerifyContext, + ) { + } + + override fun onSessionRequest( + sessionRequest: Wallet.Model.SessionRequest, + verifyContext: Wallet.Model.VerifyContext, + ) { + } + + override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {} + + override fun onSessionUpdateResponse(sessionUpdateResponse: Wallet.Model.SessionUpdateResponse) {} +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkSessionConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkSessionConverter.kt new file mode 100644 index 0000000000..0f2e570acd --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkSessionConverter.kt @@ -0,0 +1,15 @@ +package com.tangem.data.walletconnect.utils + +import com.reown.walletkit.client.Wallet +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession +import com.tangem.utils.converter.Converter + +internal object WcSdkSessionConverter : Converter { + + override fun convert(value: Wallet.Model.Session): WcSdkSession { + return WcSdkSession( + topic = value.topic, + appMetaData = value.metaData?.let { WcAppMetaDataConverter.convert(it) } ?: WcAppMetaDataConverter.empty, + ) + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkSessionRequestConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkSessionRequestConverter.kt new file mode 100644 index 0000000000..603699848e --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkSessionRequestConverter.kt @@ -0,0 +1,16 @@ +package com.tangem.data.walletconnect.utils + +import com.reown.walletkit.client.Wallet +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.utils.converter.Converter + +internal object WcSdkSessionRequestConverter : Converter { + + override fun convert(value: Wallet.Model.SessionRequest): WcSdkSessionRequest { + return WcSdkSessionRequest( + topic = value.topic, + chainId = value.chainId, + request = JSONRPCRequestConverter.convert(value.request), + ) + } +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index e17b2c880c..35bc64c215 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -208,6 +208,10 @@ internal class DefaultWalletsRepository( .getObjectMap(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) .map { it[userWalletId.stringValue] == true } + override fun nftEnabledStatuses(): Flow> = appPreferencesStore + .getObjectMap(PreferencesKeys.WALLETS_NFT_ENABLED_STATES_KEY) + .map { it.mapKeys { UserWalletId(it.key) } } + override suspend fun enableNFT(userWalletId: UserWalletId) { appPreferencesStore.editData { it.setObjectMap( diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardSdkConfigRepository.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardSdkConfigRepository.kt index ecf06c78d8..c985aac75d 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardSdkConfigRepository.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardSdkConfigRepository.kt @@ -36,4 +36,7 @@ interface CardSdkConfigRepository { /** Set linked terminal by [isLinked] */ fun setLinkedTerminal(isLinked: Boolean?) + + /** Set [flag] that determines whether to use Prod environment for Tangem API */ + fun setTangemApiProdEnvFlag(flag: Boolean) } \ No newline at end of file diff --git a/domain/core/build.gradle.kts b/domain/core/build.gradle.kts index 46d2670d68..9910d828a3 100644 --- a/domain/core/build.gradle.kts +++ b/domain/core/build.gradle.kts @@ -10,4 +10,9 @@ dependencies { api(deps.arrow.fx) implementation(deps.kotlin.serialization) + + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowCachingSupplier.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowCachingSupplier.kt new file mode 100644 index 0000000000..2a86b81acf --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowCachingSupplier.kt @@ -0,0 +1,59 @@ +package com.tangem.domain.core.flow + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.update + +private typealias FlowsStore = MutableStateFlow>> + +/** + * [Flow] supplier with caching mechanism + * + * @param Producer producer of flow [Flow] + * @param Params type of data that required to get [Flow] + * @param Data data type of [Flow] + * @property flowsStore store of flows + */ +abstract class FlowCachingSupplier, Params : Any, Data : Any>( + private val flowsStore: FlowsStore = MutableStateFlow(value = emptyMap()), +) : FlowSupplier { + + /** Factory of [FlowProducer] */ + abstract val factory: FlowProducer.Factory + + /** Key creator */ + abstract val keyCreator: (Params) -> String + + /** + * Supply [Flow] by [params]. + + */ + override operator fun invoke(params: Params): Flow { + val key = keyCreator(params) + val saved = flowsStore.value[key] + + if (saved != null) return saved + + val flowProducer = factory.create(params = params) + + return runCatching(flowProducer::produceWithFallback) + .onSuccess { flow -> + flowsStore.update { + it.toMutableMap().apply { + put(key = key, value = flow) + } + } + } + .getOrThrow() + .catch { cause -> + flowsStore.update { + it.toMutableMap().apply { + remove(key) + } + } + + throw cause + } + } +} \ No newline at end of file diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowFetcher.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowFetcher.kt new file mode 100644 index 0000000000..3fcba3d9d4 --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowFetcher.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.core.flow + +import arrow.core.Either + +/** + * Flow fetcher + * + * @param Params data that required to fetch flow + * +[REDACTED_AUTHOR] + */ +interface FlowFetcher { + + suspend operator fun invoke(params: Params): Either +} \ No newline at end of file diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt new file mode 100644 index 0000000000..92e95f552d --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowProducer.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.core.flow + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.retryWhen + +/** + * [Flow] producer + * + * @param Data data type of [Flow] + * +[REDACTED_AUTHOR] + */ +interface FlowProducer { + + /** Fallback value if [Flow] throws exception */ + val fallback: Data + + /** Produce [Flow] */ + fun produce(): Flow + + /** Produce [Flow] with retry mechanism */ + fun produceWithFallback(): Flow { + return produce().retryWhen { _, _ -> + emit(value = fallback) + + delay(timeMillis = 2000) + + true + } + } + + /** Factory for creating [Producer]. It helps to provide [Params] by constructor */ + interface Factory> { + + /** Create [Producer] using [params] */ + fun create(params: Params): Producer + } +} \ No newline at end of file diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowSupplier.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowSupplier.kt new file mode 100644 index 0000000000..8de04d12bd --- /dev/null +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/flow/FlowSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.core.flow + +import kotlinx.coroutines.flow.Flow + +/** + * [Flow] supplier + * + * @param Params type of data that required to get [Flow] + * @param Data data type of [Flow] + * +[REDACTED_AUTHOR] + */ +interface FlowSupplier { + + /** Supply [Flow] by [params] */ + operator fun invoke(params: Params): Flow +} \ No newline at end of file diff --git a/domain/core/src/main/kotlin/com/tangem/domain/core/utils/EitherExt.kt b/domain/core/src/main/kotlin/com/tangem/domain/core/utils/EitherExt.kt index 457f3e4f5b..b961210633 100644 --- a/domain/core/src/main/kotlin/com/tangem/domain/core/utils/EitherExt.kt +++ b/domain/core/src/main/kotlin/com/tangem/domain/core/utils/EitherExt.kt @@ -2,7 +2,9 @@ package com.tangem.domain.core.utils import arrow.core.Either import com.tangem.domain.core.lce.Lce +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext /** * [Flow] of [Either] @@ -34,4 +36,15 @@ inline fun Either.toLce(isStillLoading: } } } +} + +@JvmName("tryCatchWithDispatch") +suspend inline fun Either.Companion.catchOn( + dispatcher: CoroutineDispatcher, + crossinline function: suspend () -> R, +): Either { + return withContext( + context = dispatcher, + block = { catch { function() } }, + ) } \ No newline at end of file diff --git a/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt b/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt new file mode 100644 index 0000000000..42822e7796 --- /dev/null +++ b/domain/core/src/test/kotlin/com/tangem/domain/core/flow/FlowCachingSupplierTest.kt @@ -0,0 +1,139 @@ +package com.tangem.domain.core.flow + +import com.google.common.truth.Truth +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 FlowCachingSupplierTest { + + private val factory = mockk() + private val errorFactory = mockk() + + @Test + fun `flow was created before`() = runTest { + val flowsStore = MutableStateFlow>>(value = emptyMap()) + val supplier = MockFlowCachingSupplier(factory = factory, flowsStore = flowsStore) + + val params = 1 + + every { factory.create(params = params) } returns MockFlowProducer(params = params) + + val actual = supplier(params = params) + + verify { factory.create(params = params) } + + Truth.assertThat(actual.first()).isEqualTo("test_1") + + Truth.assertThat(flowsStore.value.size).isEqualTo(1) + Truth.assertThat(flowsStore.value.containsKey("mock_1")).isTrue() + Truth.assertThat(flowsStore.value["mock_1"]!!.first()).isEqualTo("test_1") + } + + @Test + fun `flow wasn't created before`() = runTest { + val storedFlow = flowOf("test_1") + val storedMap = mapOf("mock_1" to storedFlow) + val flowsStore = MutableStateFlow(value = storedMap) + + val supplier = MockFlowCachingSupplier(factory = factory, flowsStore = flowsStore) + + val params = 1 + + val actual = supplier(params = params) + + verify(inverse = true) { factory.create(params = params) } + + Truth.assertThat(actual).isEqualTo(storedFlow) + Truth.assertThat(flowsStore.value).isEqualTo(storedMap) + } + + @Test + fun `flow wasn't created before and store isn't empty`() = runTest { + val flowsStore = MutableStateFlow( + value = mapOf("mock_1" to flowOf("test_1")), + ) + + val supplier = MockFlowCachingSupplier(factory = factory, flowsStore = flowsStore) + + val params = 2 + + every { factory.create(params = params) } returns MockFlowProducer(params = params) + + val actual = supplier(params = params) + + verify { factory.create(params = params) } + + Truth.assertThat(actual.first()).isEqualTo("test_2") + + Truth.assertThat(flowsStore.value.size).isEqualTo(2) + Truth.assertThat(flowsStore.value.keys).isEqualTo(setOf("mock_1", "mock_2")) + Truth.assertThat(flowsStore.value["mock_1"]!!.first()).isEqualTo("test_1") + Truth.assertThat(flowsStore.value["mock_2"]!!.first()).isEqualTo("test_2") + } + + @Test + fun `flow throws exception`() = runTest { + val flowsStore = MutableStateFlow>>(value = emptyMap()) + val supplier = MockErrorFlowCachingSupplier(factory = errorFactory, flowsStore = flowsStore) + + val params = 1 + + every { errorFactory.create(params = params) } returns MockErrorFlowProducer() + + val actual = supplier(params = params) + + verify { errorFactory.create(params = params) } + + Truth.assertThat(actual.first()).isEqualTo("fallback") + Truth.assertThat(flowsStore.value.size).isEqualTo(1) + } + + private class MockFlowCachingSupplier( + override val factory: FlowProducer.Factory, + flowsStore: MutableStateFlow>>, + ) : FlowCachingSupplier(flowsStore = flowsStore) { + + override val keyCreator: (Int) -> String = { "mock_$it" } + } + + private class MockFlowProducer(private val params: Int) : FlowProducer { + + override val fallback: String + get() = "fallback" + + override fun produce(): Flow = flowOf("test_$params") + + class Factory : FlowProducer.Factory { + override fun create(params: Int): MockFlowProducer = MockFlowProducer(params = params) + } + } + + private class MockErrorFlowCachingSupplier( + override val factory: FlowProducer.Factory, + flowsStore: MutableStateFlow>>, + ) : FlowCachingSupplier(flowsStore = flowsStore) { + + override val keyCreator: (Int) -> String = { "mock_$it" } + } + + private class MockErrorFlowProducer : FlowProducer { + + override val fallback: String + get() = "fallback" + + override fun produce(): Flow = flow { + throw IllegalStateException() + } + + class Factory : FlowProducer.Factory { + override fun create(params: Int): MockErrorFlowProducer = MockErrorFlowProducer() + } + } +} \ No newline at end of file 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 570f0f9b56..7bb6e53a9b 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 @@ -31,7 +31,7 @@ class SendFeedbackEmailUseCase( subject = emailSubjectResolver.resolve(type), message = createMessage(type), // Temporally user data is not sent - file = null, // feedbackRepository.getLogFile(), + file = null, // feedbackRepository.getZipLogFile(), ) feedbackRepository.sendEmail(email) diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt index ae50655518..802c36dc7d 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt @@ -27,5 +27,7 @@ interface FeedbackRepository { fun getLogFile(): File? + suspend fun getZipLogFile(): File? + fun sendEmail(feedbackEmail: FeedbackEmail) } \ No newline at end of file diff --git a/domain/legacy/build.gradle.kts b/domain/legacy/build.gradle.kts index 11e080b895..fc7fa7889b 100644 --- a/domain/legacy/build.gradle.kts +++ b/domain/legacy/build.gradle.kts @@ -12,6 +12,7 @@ android { dependencies { implementation(projects.core.datasource) implementation(projects.core.utils) + implementation(projects.core.error) implementation(projects.common) implementation(projects.libs.auth) implementation(projects.libs.blockchainSdk) diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt b/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt index 29ce08c107..a42c4a5b8d 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/CardTypesResolver.kt @@ -26,6 +26,8 @@ interface CardTypesResolver { fun isDevKit(): Boolean + fun isSingleWallet(): Boolean + fun isSingleWalletWithToken(): Boolean fun isMultiwalletAllowed(): Boolean diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt b/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt index 46035bd37d..5b997342b9 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/TangemCardTypesResolver.kt @@ -50,6 +50,8 @@ internal class TangemCardTypesResolver( override fun isDevKit(): Boolean = card.batchId == DEV_KIT_CARD_BATCH_ID + override fun isSingleWallet(): Boolean = !isMultiwalletAllowed() && !isSingleWalletWithToken() && !isVisaWallet() + override fun isSingleWalletWithToken(): Boolean = walletData?.token != null && !isMultiwalletAllowed() override fun isMultiwalletAllowed(): Boolean { diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/configs/Wallet2CardConfig.kt b/domain/legacy/src/main/java/com/tangem/domain/common/configs/Wallet2CardConfig.kt index aa0a5e23e6..b9fbfffbf3 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/configs/Wallet2CardConfig.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/configs/Wallet2CardConfig.kt @@ -205,6 +205,8 @@ data object Wallet2CardConfig : CardConfig { Blockchain.ScrollTestnet -> EllipticCurve.Secp256k1 Blockchain.ZkLinkNova -> EllipticCurve.Secp256k1 Blockchain.ZkLinkNovaTestnet -> EllipticCurve.Secp256k1 + Blockchain.Pepecoin -> EllipticCurve.Secp256k1 + Blockchain.PepecoinTestnet -> EllipticCurve.Secp256k1 } } } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/visa/VisaWalletPublicKeyUtility.kt b/domain/legacy/src/main/java/com/tangem/domain/common/visa/VisaWalletPublicKeyUtility.kt index e7500baf4f..2bc3ecbc4a 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/visa/VisaWalletPublicKeyUtility.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/visa/VisaWalletPublicKeyUtility.kt @@ -8,29 +8,33 @@ import com.tangem.blockchain.common.address.AddressType import com.tangem.common.card.EllipticCurve import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.visa.error.VisaActivationError object VisaWalletPublicKeyUtility { - fun validateExtendedPublicKey(targetAddress: String, extendedPublicKey: ExtendedPublicKey): Either = - either { - validatePublicKey( - targetAddress = targetAddress, - publicKey = extendedPublicKey.publicKey, - ).bind() - } - - fun findKeyWithoutDerivation(targetAddress: String, card: CardDTO): Either = either { - val wallet = findWalletOnSecp256k1(card).bind() - + fun validateExtendedPublicKey( + targetAddress: String, + extendedPublicKey: ExtendedPublicKey, + ): Either = either { validatePublicKey( targetAddress = targetAddress, - publicKey = wallet.publicKey, + publicKey = extendedPublicKey.publicKey, ).bind() - - wallet.publicKey } - fun generateAddressOnSecp256k1(walletPublicKey: ByteArray): Either = either { + fun findKeyWithoutDerivation(targetAddress: String, card: CardDTO): Either = + either { + val wallet = findWalletOnSecp256k1(card).bind() + + validatePublicKey( + targetAddress = targetAddress, + publicKey = wallet.publicKey, + ).bind() + + wallet.publicKey + } + + fun generateAddressOnSecp256k1(walletPublicKey: ByteArray): Either = either { val addresses = catch( block = { VisaUtilities.visaBlockchain.makeAddresses( @@ -39,27 +43,22 @@ object VisaWalletPublicKeyUtility { curve = EllipticCurve.Secp256k1, ) }, - catch = { raise(Error.FailedToCreateAddress) }, + catch = { raise(VisaActivationError.FailedToCreateAddress) }, ) - addresses.firstOrNull { it.type == AddressType.Default } ?: raise(Error.FailedToCreateAddress) + addresses.firstOrNull { it.type == AddressType.Default } ?: raise(VisaActivationError.FailedToCreateAddress) } - private fun findWalletOnSecp256k1(card: CardDTO): Either = either { - card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: raise(Error.MissingWalletOnTargetCurve) + private fun findWalletOnSecp256k1(card: CardDTO): Either = either { + card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?: raise(VisaActivationError.MissingWallet) } - private fun validatePublicKey(targetAddress: String, publicKey: ByteArray): Either = either { - val address = generateAddressOnSecp256k1(publicKey).bind() + private fun validatePublicKey(targetAddress: String, publicKey: ByteArray): Either = + either { + val address = generateAddressOnSecp256k1(publicKey).bind() - if (address.value != targetAddress) { - raise(Error.AddressNotMatched) + if (address.value != targetAddress) { + raise(VisaActivationError.AddressNotMatched) + } } - } - - enum class Error(val message: String) { - AddressNotMatched("ValidationError: Address not matched"), - FailedToCreateAddress("ValidationError: Failed to create address"), - MissingWalletOnTargetCurve("ValidationError: Missing wallet on target curve"), - } } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/exchange/ExchangeableState.kt b/domain/legacy/src/main/java/com/tangem/domain/exchange/ExchangeableState.kt deleted file mode 100644 index 9a4f140cf1..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/exchange/ExchangeableState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.domain.exchange - -sealed interface ExchangeableState { - data object Exchangeable : ExchangeableState - data object NotExchangeable : ExchangeableState - data object Loading : ExchangeableState - data object Error : ExchangeableState - data object AssetNotFound : ExchangeableState -} \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/exchange/ExpressAvailabilityState.kt b/domain/legacy/src/main/java/com/tangem/domain/exchange/ExpressAvailabilityState.kt new file mode 100644 index 0000000000..a0e960f17e --- /dev/null +++ b/domain/legacy/src/main/java/com/tangem/domain/exchange/ExpressAvailabilityState.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.exchange + +sealed interface ExpressAvailabilityState { + data object Available : ExpressAvailabilityState + data object NotExchangeable : ExpressAvailabilityState + data object NotOnrampable : ExpressAvailabilityState + data object Loading : ExpressAvailabilityState + data object Error : ExpressAvailabilityState + data object AssetNotFound : ExpressAvailabilityState +} \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt b/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt index 180a8b16b5..e8a8f3a363 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt @@ -18,7 +18,7 @@ interface RampStateManager { scanResponse: ScanResponse, userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, - ): Boolean + ): ScenarioUnavailabilityReason /** * Check if [CryptoCurrency] is available for sell @@ -31,7 +31,10 @@ interface RampStateManager { status: CryptoCurrencyStatus, ): Either - suspend fun availableForSwap(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): ExchangeableState + suspend fun availableForSwap( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): ScenarioUnavailabilityReason suspend fun fetchBuyServiceData() 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 ce2a817c95..d15b9f68bd 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -667,7 +667,7 @@ class DefaultWalletManagersFacade( return walletManager.getAssets(address, collectionIdentifier) } - override suspend fun getAsset( + override suspend fun getNFTAsset( userWalletId: UserWalletId, network: Network, collectionIdentifier: NFTCollection.Identifier, @@ -682,6 +682,27 @@ class DefaultWalletManagersFacade( return walletManager.getAsset(collectionIdentifier, assetIdentifier) } + override suspend fun getNFTSalePrice( + userWalletId: UserWalletId, + network: Network, + collectionIdentifier: NFTCollection.Identifier, + assetIdentifier: NFTAsset.Identifier, + ): NFTAsset.SalePrice? { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) ?: return null + return walletManager.getSalePrice(collectionIdentifier, assetIdentifier) + } + + override suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + val initializableAccountWalletManger = walletManager as? InitializableAccount ?: return true + return initializableAccountWalletManger.accountInitializationState == InitializableAccount.State.INITIALIZED + } + private fun updateWalletManagerTokensIfNeeded(walletManager: WalletManager, tokens: Set) { if (tokens.isEmpty()) return 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 d1554a2118..b40c497186 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -259,10 +259,23 @@ interface WalletManagersFacade { collectionIdentifier: NFTCollection.Identifier, ): List - suspend fun getAsset( + suspend fun getNFTAsset( userWalletId: UserWalletId, network: Network, collectionIdentifier: NFTCollection.Identifier, assetIdentifier: NFTAsset.Identifier, ): NFTAsset? + + suspend fun getNFTSalePrice( + userWalletId: UserWalletId, + network: Network, + collectionIdentifier: NFTCollection.Identifier, + assetIdentifier: NFTAsset.Identifier, + ): NFTAsset.SalePrice? + + /** + * If wallet manager implements [InitializableAccount] then returns [InitializableAccount.isAccountInitialized] + * value. Otherwise always return true + */ + suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean } \ No newline at end of file diff --git a/domain/legacy/src/test/java/com/tangem/domain/common/configs/Wallet2CardConfigTest.kt b/domain/legacy/src/test/java/com/tangem/domain/common/configs/Wallet2CardConfigTest.kt index d80b9be554..b97e91d9d1 100644 --- a/domain/legacy/src/test/java/com/tangem/domain/common/configs/Wallet2CardConfigTest.kt +++ b/domain/legacy/src/test/java/com/tangem/domain/common/configs/Wallet2CardConfigTest.kt @@ -162,6 +162,8 @@ class Wallet2CardConfigTest { Blockchain.ScrollTestnet to EllipticCurve.Secp256k1, Blockchain.ZkLinkNova to EllipticCurve.Secp256k1, Blockchain.ZkLinkNovaTestnet to EllipticCurve.Secp256k1, + Blockchain.Pepecoin to EllipticCurve.Secp256k1, + Blockchain.PepecoinTestnet to EllipticCurve.Secp256k1, ) @Test diff --git a/domain/manage-tokens/build.gradle.kts b/domain/manage-tokens/build.gradle.kts index 958503009f..ef5facece8 100644 --- a/domain/manage-tokens/build.gradle.kts +++ b/domain/manage-tokens/build.gradle.kts @@ -11,8 +11,9 @@ android { dependencies { /* Domain */ - api(projects.domain.manageTokens.models) api(projects.domain.core) + api(projects.domain.manageTokens.models) + api(projects.domain.networks) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens.models) implementation(projects.domain.staking) @@ -22,4 +23,10 @@ dependencies { /* Core */ api(projects.core.pagination) + testImplementation(projects.core.pagination) + + /* Tests */ + testImplementation(deps.test.junit) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetDistinctManagedCurrenciesUseCase.kt b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetDistinctManagedCurrenciesUseCase.kt new file mode 100644 index 0000000000..605a11db0f --- /dev/null +++ b/domain/manage-tokens/src/main/kotlin/com/tangem/domain/managetokens/GetDistinctManagedCurrenciesUseCase.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.managetokens + +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.pagination.Batch +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +class GetDistinctManagedCurrenciesUseCase( + private val coroutineDispatchersProvider: CoroutineDispatcherProvider, +) { + + // FIXME: Add interception functionality to BatchFlow state and call this on domain + // [REDACTED_JIRA] + suspend operator fun invoke( + batches: List>>, + ): List>> = withContext(coroutineDispatchersProvider.default) { + val seenIds = mutableSetOf() + + batches.map { batch -> + val filtered = batch.data.filter { currency -> + seenIds.add(currency.id) + } + batch.copy(data = filtered) + } + } +} \ No newline at end of file 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 1cec10d0c1..9f78e90712 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 @@ -5,7 +5,9 @@ import arrow.core.flatten 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.staking.repositories.StakingRepository +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -23,6 +25,8 @@ class SaveManagedTokensUseCase( private val derivationsRepository: DerivationsRepository, private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -92,11 +96,20 @@ class SaveManagedTokensUseCase( val networkToUpdate = currenciesToAdd.map { it.network } .subtract(existingCurrencies.map { it.network }.toSet()) - networksRepository.getNetworkStatusesSync( - userWalletId = userWalletId, - networks = networksToUpdate + networkToUpdate, - refresh = true, - ) + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + multiNetworkStatusFetcher( + MultiNetworkStatusFetcher.Params( + userWalletId = userWalletId, + networks = networksToUpdate + networkToUpdate, + ), + ) + } else { + networksRepository.getNetworkStatusesSync( + userWalletId = userWalletId, + networks = networksToUpdate + networkToUpdate, + refresh = true, + ) + } } private suspend fun refreshUpdatedYieldBalances( diff --git a/domain/manage-tokens/src/test/kotlin/com/tangem/domain/managetokens/GetDistinctManagedCurrenciesUseCaseTest.kt b/domain/manage-tokens/src/test/kotlin/com/tangem/domain/managetokens/GetDistinctManagedCurrenciesUseCaseTest.kt new file mode 100644 index 0000000000..69c9a4370c --- /dev/null +++ b/domain/manage-tokens/src/test/kotlin/com/tangem/domain/managetokens/GetDistinctManagedCurrenciesUseCaseTest.kt @@ -0,0 +1,112 @@ +package com.tangem.domain.managetokens + +import com.tangem.domain.managetokens.model.ManagedCryptoCurrency +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.pagination.Batch +import org.junit.Test +import java.util.UUID +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest + +class GetDistinctManagedTokensUseCaseTest { + + private val useCase = GetDistinctManagedCurrenciesUseCase(TestingCoroutineDispatcherProvider()) + + @Test + fun `GIVEN generated batch with multiple duplicates WHEN invoke THEN batches with unique manage currencies`() = + runTest { + // GIVEN + val totalBatches = 10 + val batchSize = 20 + val duplicatePerBatch = 5 + val uniquePerBatch = batchSize - duplicatePerBatch + val globalTokens = mutableListOf() + val batches = mutableListOf>>() + + repeat(totalBatches) { batchIndex -> + val duplicates = if (batchIndex == 0) { + emptyList() + } else { + globalTokens.take(duplicatePerBatch) + } + + val newTokens = List(uniquePerBatch) { + val token = testToken(id = "token_${batchIndex}_$it") + globalTokens += token + token + } + + val batch = Batch(batchIndex, duplicates + newTokens) + batches += batch + } + + // WHEN + val result = useCase(batches) + + // THEN + val seenIds = mutableSetOf() + + result.forEach { batch -> + batch.data.forEach { token -> + assertThat(seenIds).doesNotContain(token.id) + seenIds.add(token.id) + } + } + + val totalTokens = result.sumOf { it.data.size } + assertThat(totalTokens).isEqualTo(totalBatches * (batchSize - duplicatePerBatch)) + } + + @Test + fun `GIVEN static batch with multiple duplicates WHEN invoke THEN batches with unique manage currencies`() = + runTest { + // GIVEN + val token1 = testToken(id = "A", name = "Delta") + val token2 = testToken(id = "B", name = "Delta") + val token3 = testToken(id = "C", name = "Delta") + val token4 = testToken(id = "D", name = "Delta") + val token5 = testToken(id = "E", name = "Delta") + val token6 = testToken(id = "F", name = "Delta") + val token7 = testToken(id = "G", name = "Delta") + val token8 = testToken(id = "H", name = "Delta") + val token9 = testToken(id = "I", name = "Delta") + val token10 = testToken(id = "J", name = "Delta") + + val batches = listOf( + Batch(0, listOf(token1, token2, token3, token4, token5)), + Batch(1, listOf(token1, token7, token8, token9, token6)), + Batch(2, listOf(token2, token8, token8, token6, token10)), + ) + + // WHEN + val result = useCase(batches) + + // THEN + val seenIds = mutableSetOf() + + result.forEach { batch -> + batch.data.forEach { token -> + assertThat(seenIds).doesNotContain(token.id) + seenIds.add(token.id) + } + } + + val totalTokens = result.sumOf { it.data.size } + assertThat(totalTokens).isEqualTo(10) + } +} + +fun testToken( + id: String = UUID.randomUUID().toString(), + name: String = "Test Token", + symbol: String = "TTK", +): ManagedCryptoCurrency { + return ManagedCryptoCurrency.Token( + id = ManagedCryptoCurrency.ID(id), + name = name, + symbol = symbol, + iconUrl = "null", + availableNetworks = emptyList(), + addedIn = emptySet(), + ) +} \ No newline at end of file diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index f5a22e3d18..6ba3a16199 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -15,12 +15,13 @@ dependencies { api(projects.domain.appCurrency.models) api(projects.domain.card) api(projects.domain.core) - api(projects.domain.markets.models) - api(projects.domain.wallets.models) - api(projects.domain.models) api(projects.domain.legacy) - api(projects.domain.wallets) + api(projects.domain.markets.models) + api(projects.domain.models) + api(projects.domain.networks) api(projects.domain.staking) + api(projects.domain.wallets) + api(projects.domain.wallets.models) implementation(projects.domain.tokens.models) implementation(projects.domain.tokens) diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt index ce2ff4bb71..9cf4b69b48 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenQuotesShort.kt @@ -4,9 +4,9 @@ import java.math.BigDecimal data class TokenQuotesShort( val currentPrice: BigDecimal, - val h24ChangePercent: BigDecimal, - val weekChangePercent: BigDecimal, - val monthChangePercent: BigDecimal, + val h24ChangePercent: BigDecimal?, + val weekChangePercent: BigDecimal?, + val monthChangePercent: BigDecimal?, ) fun TokenQuotesShort.toFull() = TokenQuotes( 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 352fb55d85..4d086677b5 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 @@ -3,7 +3,9 @@ package com.tangem.domain.markets 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.staking.repositories.StakingRepository +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -20,6 +22,7 @@ import com.tangem.domain.wallets.models.UserWalletId * [REDACTED_AUTHOR] */ +@Suppress("LongParameterList") class SaveMarketTokensUseCase( private val derivationsRepository: DerivationsRepository, private val marketsTokenRepository: MarketsTokenRepository, @@ -27,6 +30,8 @@ class SaveMarketTokensUseCase( private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -73,11 +78,20 @@ class SaveMarketTokensUseCase( } private suspend fun refreshUpdatedNetworks(userWalletId: UserWalletId, addedCurrencies: List) { - networksRepository.getNetworkStatusesSync( - userWalletId = userWalletId, - networks = addedCurrencies.map(CryptoCurrency::network).toSet(), - refresh = true, - ) + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + multiNetworkStatusFetcher( + MultiNetworkStatusFetcher.Params( + userWalletId = userWalletId, + networks = addedCurrencies.map(CryptoCurrency::network).toSet(), + ), + ) + } else { + networksRepository.getNetworkStatusesSync( + userWalletId = userWalletId, + networks = addedCurrencies.map(CryptoCurrency::network).toSet(), + refresh = true, + ) + } } private suspend fun refreshUpdatedYieldBalances( diff --git a/domain/networks/.gitignore b/domain/networks/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/networks/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/networks/build.gradle.kts b/domain/networks/build.gradle.kts new file mode 100644 index 0000000000..8207889da2 --- /dev/null +++ b/domain/networks/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + api(projects.domain.core) + api(projects.domain.tokens.models) + api(projects.domain.wallets.models) +} \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt new file mode 100644 index 0000000000..0710aa80d3 --- /dev/null +++ b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.networks.multi + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Fetcher of network status [Network] for wallet with [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface MultiNetworkStatusFetcher : FlowFetcher { + + data class Params(val userWalletId: UserWalletId, val networks: Set) +} \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusProducer.kt b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusProducer.kt new file mode 100644 index 0000000000..eb47772afc --- /dev/null +++ b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.networks.multi + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.tokens.model.NetworkStatus +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Producer of all networks statuses for wallet with [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface MultiNetworkStatusProducer : FlowProducer> { + + data class Params(val userWalletId: UserWalletId) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt new file mode 100644 index 0000000000..7ca732a0d2 --- /dev/null +++ b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.networks.multi + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.tokens.model.NetworkStatus + +/** + * Supplier of all networks statuses for selected wallet [MultiNetworkStatusProducer.Params] + * + * @property factory factory for creating [MultiNetworkStatusProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class MultiNetworkStatusSupplier( + override val factory: MultiNetworkStatusProducer.Factory, + override val keyCreator: (MultiNetworkStatusProducer.Params) -> String, +) : FlowCachingSupplier>() \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt new file mode 100644 index 0000000000..d9ab0fbece --- /dev/null +++ b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt @@ -0,0 +1,26 @@ +package com.tangem.domain.networks.single + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Fetcher of network status [Network] for wallet with [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface SingleNetworkStatusFetcher : FlowFetcher { + + /** + * Params + * + * @property userWalletId wallet id + * @property network network + * @property applyRefresh flag that determines whether to apply refresh (see DefaultMultiNetworkStatusFetcher) + */ + data class Params( + val userWalletId: UserWalletId, + val network: Network, + val applyRefresh: Boolean = true, + ) +} \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusProducer.kt b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusProducer.kt new file mode 100644 index 0000000000..78262a1049 --- /dev/null +++ b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusProducer.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.networks.single + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkStatus +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Producer of network status [Network] for wallet with [UserWalletId] + * +[REDACTED_AUTHOR] + */ +interface SingleNetworkStatusProducer : FlowProducer { + + data class Params(val userWalletId: UserWalletId, val network: Network) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt new file mode 100644 index 0000000000..12afefd145 --- /dev/null +++ b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.networks.single + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.tokens.model.NetworkStatus + +/** + * Supplier of network status for selected wallet [SingleNetworkStatusProducer.Params] + * + * @property factory factory for creating [SingleNetworkStatusProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class SingleNetworkStatusSupplier( + override val factory: SingleNetworkStatusProducer.Factory, + override val keyCreator: (SingleNetworkStatusProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/nft/build.gradle.kts b/domain/nft/build.gradle.kts index 2159e615a7..b5ce316d58 100644 --- a/domain/nft/build.gradle.kts +++ b/domain/nft/build.gradle.kts @@ -13,6 +13,8 @@ dependencies { implementation(deps.arrow.core) implementation(deps.kotlin.coroutines) + implementation(projects.core.utils) + implementation(projects.domain.core) implementation(projects.domain.models) implementation(projects.domain.nft.models) diff --git a/domain/nft/models/build.gradle.kts b/domain/nft/models/build.gradle.kts index 92208c0a6b..ce4aec98eb 100644 --- a/domain/nft/models/build.gradle.kts +++ b/domain/nft/models/build.gradle.kts @@ -8,7 +8,10 @@ dependencies { implementation(deps.arrow.core) implementation(deps.kotlin.coroutines) + implementation(projects.domain.core) implementation(projects.domain.models) - implementation(projects.domain.tokens.models) + + /* Utils */ + implementation(deps.kotlin.serialization) } \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt index f2cbe202c4..3e2558f928 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 @@ -2,53 +2,62 @@ package com.tangem.domain.nft.models import com.tangem.domain.models.StatusSource import com.tangem.domain.tokens.model.Network +import kotlinx.serialization.Serializable -sealed class NFTAsset { - abstract val id: Identifier +@Serializable +data class NFTAsset( + val id: Identifier, + val collectionId: NFTCollection.Identifier, + val network: Network, + val contractType: String, + val owner: String?, + val name: String?, + val description: String?, + val salePrice: NFTSalePrice, + val rarity: Rarity?, + val media: Media?, + val traits: List, + val source: StatusSource, +) { - data class Value( - override val id: Identifier, - val collectionId: NFTCollection.Identifier, - val network: Network, - val contractType: String, - val owner: String?, - val name: String?, - val description: String?, - val salePrice: NFTSalePrice, - val rarity: Rarity?, - val media: Media?, - val traits: List, - val source: StatusSource, - ) : NFTAsset() { + @Serializable + data class Media( + val mimetype: String?, + val url: String, + ) - data class Media( - val mimetype: String, - val url: String, - ) + @Serializable + data class Rarity( + val rank: String, + val label: String, + ) - data class Rarity( - val rank: String, - val label: String, - ) - - data class Trait( - val name: String, - val value: String, - ) - } - - data class Error( - override val id: Identifier, - ) : NFTAsset() + @Serializable + data class Trait( + val name: String, + val value: String, + ) + @Serializable sealed class Identifier { + abstract val stringValue: String + + @Serializable data class EVM( val tokenId: String, val tokenAddress: String, - ) : Identifier() + ) : Identifier() { + override val stringValue: String = "${tokenAddress}_$tokenId" + } - data class TON(val tokenAddress: String) : Identifier() + @Serializable + data class TON(val tokenAddress: String) : Identifier() { + override val stringValue: String = tokenAddress + } - data object Unknown : Identifier() + @Serializable + data object Unknown : Identifier() { + override val stringValue: String = "" + } } } \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt index 55563ea813..d75d39c6de 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt @@ -1,7 +1,10 @@ package com.tangem.domain.nft.models +import com.tangem.domain.models.StatusSource import com.tangem.domain.tokens.model.Network +import kotlinx.serialization.Serializable +@Serializable data class NFTCollection( val id: Identifier, val network: Network, @@ -9,11 +12,35 @@ data class NFTCollection( val description: String?, val logoUrl: String?, val count: Int, - val assets: List = emptyList(), + val assets: Assets, ) { + @Serializable + sealed class Assets { + @Serializable + data object Empty : Assets() + + @Serializable + data object Loading : Assets() + + @Serializable + data object Failed : Assets() + + @Serializable + data class Value( + val items: List, + val source: StatusSource, + ) : Assets() + } + + @Serializable sealed class Identifier { + @Serializable data class EVM(val tokenAddress: String) : Identifier() + + @Serializable data class TON(val contractAddress: String?) : Identifier() + + @Serializable data object Unknown : Identifier() } } \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt index b4e4accc54..42eb1cc51e 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollections.kt @@ -17,4 +17,41 @@ data class NFTCollections( val error: Throwable, ) : Content() } + + companion object { + fun empty(network: Network) = NFTCollections( + network = network, + content = Content.Collections( + collections = null, + source = StatusSource.ACTUAL, + ), + ) + } +} + +fun List.allCollectionsFailed() = this.all { + it.content is NFTCollections.Content.Error +} + +fun List.anyCollectionFailed() = this.any { + it.content is NFTCollections.Content.Error || + it.content is NFTCollections.Content.Collections && + it.content.source == StatusSource.ONLY_CACHE +} + +fun List.allLoadedCollectionsEmpty() = this + .map { it.content } + .filterIsInstance() + .all { it.collections.isNullOrEmpty() } + +fun List.allCollectionsLoaded() = this.all { + val content = it.content + content is NFTCollections.Content.Collections && + content.source != StatusSource.CACHE +} + +fun List.allCollectionsEmpty() = this.all { + val content = it.content + content is NFTCollections.Content.Collections && + content.collections.isNullOrEmpty() } \ No newline at end of file diff --git a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt index 3ef25c436e..52cc43d072 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTSalePrice.kt @@ -1,23 +1,31 @@ package com.tangem.domain.nft.models -import com.tangem.domain.models.StatusSource -import java.math.BigDecimal +import com.tangem.domain.core.serialization.SerializedBigDecimal +import kotlinx.serialization.Serializable +@Serializable sealed class NFTSalePrice { abstract val assetId: NFTAsset.Identifier + @Serializable data class Empty( override val assetId: NFTAsset.Identifier, ) : NFTSalePrice() + @Serializable + data class Loading( + override val assetId: NFTAsset.Identifier, + ) : NFTSalePrice() + + @Serializable data class Error( override val assetId: NFTAsset.Identifier, ) : NFTSalePrice() + @Serializable data class Value( override val assetId: NFTAsset.Identifier, - val value: BigDecimal, + val value: SerializedBigDecimal, val symbol: String, - val source: StatusSource, ) : NFTSalePrice() } \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionAssetsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionAssetsUseCase.kt new file mode 100644 index 0000000000..66d5f1c780 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionAssetsUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.nft + +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +class FetchNFTCollectionAssetsUseCase( + private val nftRepository: NFTRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network, collectionId: NFTCollection.Identifier) { + nftRepository.refreshAssets(userWalletId, network, collectionId) + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt new file mode 100644 index 0000000000..e8ecd813c0 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FetchNFTCollectionsUseCase.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.nft + +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.models.UserWalletId + +class FetchNFTCollectionsUseCase( + private val currenciesRepository: CurrenciesRepository, + private val nftRepository: NFTRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId) { + val currencies = currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) + nftRepository.refreshCollections(userWalletId, currencies.map { it.network }.distinct()) + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt new file mode 100644 index 0000000000..3308232789 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/FilterNFTAvailableNetworksUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.nft + +import com.tangem.domain.tokens.model.Network +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext + +class FilterNFTAvailableNetworksUseCase( + private val dispatchers: CoroutineDispatcherProvider, +) { + suspend operator fun invoke(networks: List, searchQuery: String): List = + withContext(dispatchers.default) { + networks.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.currencySymbol.contains(searchQuery, ignoreCase = true) + } + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTAvailableNetworksUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTAvailableNetworksUseCase.kt new file mode 100644 index 0000000000..203f12e012 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTAvailableNetworksUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.nft + +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetNFTAvailableNetworksUseCase( + private val currenciesRepository: CurrenciesRepository, + private val nftRepository: NFTRepository, +) { + operator fun invoke(userWalletId: UserWalletId): Flow> = currenciesRepository + .getWalletCurrenciesUpdates(userWalletId) + .map { cryptoCurrencies -> + cryptoCurrencies + .map { cryptoCurrency -> cryptoCurrency.network } + .distinct() + .filter { nftRepository.isNFTSupported(it) } + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt new file mode 100644 index 0000000000..6619b45d46 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTCollectionsUseCase.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.nft + +import com.tangem.domain.nft.models.NFTCollections +import com.tangem.domain.nft.repository.NFTRepository +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest + +class GetNFTCollectionsUseCase( + private val currenciesRepository: CurrenciesRepository, + private val nftRepository: NFTRepository, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(userWalletId: UserWalletId): Flow> = currenciesRepository + .getWalletCurrenciesUpdates(userWalletId) + .flatMapLatest { + val networks = it + .map { cryptoCurrency -> cryptoCurrency.network } + .distinct() + nftRepository.observeCollections(userWalletId, networks) + } +} \ No newline at end of file diff --git a/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworkStatusUseCase.kt b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworkStatusUseCase.kt new file mode 100644 index 0000000000..4d0771cde1 --- /dev/null +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/GetNFTNetworkStatusUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.nft + +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkStatus +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.wallets.models.UserWalletId + +class GetNFTNetworkStatusUseCase( + private val networksRepository: NetworksRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): NetworkStatus? = networksRepository + .getNetworkStatusesSync(userWalletId, setOf(network), false) + .firstOrNull { it.network == network } +} \ 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 9a220eb202..5d811e9a39 100644 --- a/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt +++ b/domain/nft/src/main/kotlin/com/tangem/domain/nft/repository/NFTRepository.kt @@ -1,5 +1,6 @@ package com.tangem.domain.nft.repository +import com.tangem.domain.nft.models.NFTCollection import com.tangem.domain.nft.models.NFTCollections import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId @@ -9,4 +10,8 @@ interface NFTRepository { fun observeCollections(userWalletId: UserWalletId, networks: List): Flow> suspend fun refreshCollections(userWalletId: UserWalletId, networks: List) + + suspend fun refreshAssets(userWalletId: UserWalletId, network: Network, collectionId: NFTCollection.Identifier) + + suspend fun isNFTSupported(network: Network): Boolean } \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/GetLegacyTopUpUrlUseCase.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetLegacyTopUpUrlUseCase.kt new file mode 100644 index 0000000000..ab5a2807a0 --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/GetLegacyTopUpUrlUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.onramp + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.onramp.repositories.LegacyTopUpRepository +import com.tangem.domain.onramp.model.error.OnrampError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus + +class GetLegacyTopUpUrlUseCase( + private val legacyTopUpRepository: LegacyTopUpRepository, +) { + + suspend operator fun invoke(cryptoCurrencyStatus: CryptoCurrencyStatus): Either = + either { + val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ?: raise(OnrampError.DomainError("Wallet address is null")) + legacyTopUpRepository.getTopUpUrl( + cryptoCurrency = cryptoCurrencyStatus.currency, + walletAddress = walletAddress, + ) + } +} \ No newline at end of file diff --git a/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/LegacyTopUpRepository.kt b/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/LegacyTopUpRepository.kt new file mode 100644 index 0000000000..0b69495806 --- /dev/null +++ b/domain/onramp/src/main/java/com/tangem/domain/onramp/repositories/LegacyTopUpRepository.kt @@ -0,0 +1,23 @@ +package com.tangem.domain.onramp.repositories + +import com.tangem.domain.tokens.model.CryptoCurrency + +/** + * Support legacy onboarding note/twins to get top up URL from mercuryo + * + */ +interface LegacyTopUpRepository { + + /** + * Get top up URL from mercuryo + * + * @property cryptoCurrency crypto currency + * @property walletAddress address of wallet + */ + suspend fun getTopUpUrl(cryptoCurrency: CryptoCurrency, walletAddress: String): String + + companion object { + const val SCHEME = "https" + const val SUCCESS_URL = "https://tangem.com/success" + } +} \ No newline at end of file diff --git a/domain/staking/build.gradle.kts b/domain/staking/build.gradle.kts index 917cd0ad5f..45a68f97f1 100644 --- a/domain/staking/build.gradle.kts +++ b/domain/staking/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(deps.kotlin.serialization) implementation(deps.jodatime) + implementation(projects.domain.legacy) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) @@ -26,4 +27,5 @@ dependencies { implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + implementation(projects.libs.crypto) } \ 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 7456b68af6..ce016aee04 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 @@ -15,30 +15,7 @@ sealed class YieldBalance { override val address: String, val balance: YieldBalanceItem, val source: StatusSource, - ) : YieldBalance() { - fun getTotalWithRewardsStakingBalance(): BigDecimal { - return balance.items.sumOf { it.amount } - } - - fun getTotalStakingBalance(): BigDecimal { - return balance.items - .filterNot { it.type == BalanceType.REWARDS } - .sumOf { it.amount } - } - - fun getRewardStakingBalance(): BigDecimal { - return balance.items - .filter { it.type == BalanceType.REWARDS } - .sumOf { it.amount } - } - - fun getValidatorsCount(): Int { - return balance.items - .filterNot { it.validatorAddress.isNullOrBlank() } - .distinctBy { it.validatorAddress } - .size - } - } + ) : YieldBalance() data class Empty( override val integrationId: String?, @@ -63,9 +40,15 @@ data class BalanceItem( val validatorAddress: String?, val date: DateTime?, val pendingActions: List, + val pendingActionsConstraints: List, val isPending: Boolean, ) +data class PendingActionConstraints( + val type: StakingActionType, + val amountArg: PendingAction.PendingActionArgs.Amount?, +) + data class PendingAction( val type: StakingActionType, val passthrough: String, @@ -135,5 +118,21 @@ enum class BalanceType(val order: Int) { enum class RewardBlockType { NoRewards, Rewards, + RewardsRequirementsError, RewardUnavailable, + ; + + /** + * Indicated whether action can be performed on available reward + * For example, pending action CLAIM_REWARDS could be called + */ + val isActionable: Boolean + get() = when (this) { + NoRewards, + RewardUnavailable, + -> false + RewardsRequirementsError, + Rewards, + -> true + } } \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/CheckAccountInitializedUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/CheckAccountInitializedUseCase.kt new file mode 100644 index 0000000000..99d0d2fec7 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/CheckAccountInitializedUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.staking + +import arrow.core.Either +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWalletId + +class CheckAccountInitializedUseCase(private val walletManagersFacade: WalletManagersFacade) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either { + return Either.catch { walletManagersFacade.isAccountInitialized(userWalletId, network) } + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingTokensUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingTokensUseCase.kt index c591ae2bc2..168a65fc0f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingTokensUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingTokensUseCase.kt @@ -14,10 +14,10 @@ class FetchStakingTokensUseCase( private val stakingRepository: StakingRepository, private val stakingErrorResolver: StakingErrorResolver, ) { - suspend operator fun invoke(isRefresh: Boolean = false): Either { + suspend operator fun invoke(): Either { return either { catch( - block = { stakingRepository.fetchEnabledYields(isRefresh) }, + block = { stakingRepository.fetchEnabledYields() }, catch = { stakingErrorResolver.resolve(it) }, ) } 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 29e7148ee2..2d48d88f25 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 @@ -4,6 +4,7 @@ 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.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionType import com.tangem.domain.staking.repositories.StakingErrorResolver @@ -18,11 +19,13 @@ class InvalidatePendingTransactionsUseCase( operator fun invoke( balanceItems: List, processingActions: List, + token: Token, ): Either> { return Either.catch { val balancesToDisplay = mergeBalancesAndProcessingActions( realBalances = balanceItems, processingActions = processingActions, + token = token, ) balancesToDisplay }.mapLeft { @@ -33,16 +36,17 @@ class InvalidatePendingTransactionsUseCase( private fun mergeBalancesAndProcessingActions( realBalances: List, processingActions: List, + token: Token, ): List { val balances = realBalances.toMutableList() processingActions.forEach { action -> when (action.type) { StakingActionType.STAKE, StakingActionType.VOTE -> { - addStubStakedPendingTransaction(balances, action) + addStubStakedPendingTransaction(balances, action, token) } StakingActionType.VOTE_LOCKED -> { - addStubStakedPendingTransaction(balances, action) + addStubStakedPendingTransaction(balances, action, token) removeLockedBalance(balances, action) } StakingActionType.WITHDRAW -> { @@ -77,17 +81,22 @@ class InvalidatePendingTransactionsUseCase( } } - private fun addStubStakedPendingTransaction(balances: MutableList, action: StakingAction) { + private fun addStubStakedPendingTransaction( + balances: MutableList, + action: StakingAction, + token: Token, + ) { balances.add( BalanceItem( groupId = UUID.randomUUID().toString(), - token = balances[0].token, + token = token, type = BalanceType.STAKED, amount = action.amount, rawCurrencyId = null, - validatorAddress = action.validatorAddress ?: action.validatorAddresses?.get(0) ?: "", + validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "", date = null, pendingActions = emptyList(), + pendingActionsConstraints = emptyList(), isPending = true, ), ) diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt index b583c5f491..d97ac0f2b9 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakingRepository.kt @@ -27,7 +27,7 @@ interface StakingRepository { fun getSupportedIntegrationId(cryptoCurrencyId: CryptoCurrency.ID): String? - suspend fun fetchEnabledYields(refresh: Boolean) + suspend fun fetchEnabledYields() suspend fun getEntryInfo(cryptoCurrencyId: CryptoCurrency.ID, symbol: String): StakingEntryInfo diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/utils/YieldBalanceExt.kt b/domain/staking/src/main/java/com/tangem/domain/staking/utils/YieldBalanceExt.kt new file mode 100644 index 0000000000..fc7a759b50 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/utils/YieldBalanceExt.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.staking.utils + +import com.tangem.domain.staking.model.stakekit.BalanceType +import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.lib.crypto.BlockchainUtils +import java.math.BigDecimal + +fun YieldBalance.Data.getTotalWithRewardsStakingBalance(blockchainId: String): BigDecimal { + return if (BlockchainUtils.isIncludeStakingTotalBalance(blockchainId = blockchainId)) { + balance.items.sumOf { it.amount } + } else { + getRewardStakingBalance() + } +} + +fun YieldBalance.Data.getTotalStakingBalance(blockchainId: String): BigDecimal { + return if (BlockchainUtils.isIncludeStakingTotalBalance(blockchainId = blockchainId)) { + balance.items + .filterNot { it.type == BalanceType.REWARDS } + .sumOf { it.amount } + } else { + balance.items + .filterNot { it.type == BalanceType.REWARDS } + .sumOf { it.amount } - getRewardStakingBalance() + } +} + +fun YieldBalance.Data.getRewardStakingBalance(): BigDecimal { + return balance.items + .filter { it.type == BalanceType.REWARDS } + .sumOf { it.amount } +} + +fun YieldBalance.Data.getValidatorsCount(): Int { + return balance.items + .filterNot { it.validatorAddress.isNullOrBlank() } + .distinctBy { it.validatorAddress } + .size +} \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 9382289ad6..6ba910ceae 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(projects.features.swap.domain.models) implementation(projects.domain.promo.models) implementation(projects.domain.promo) + implementation(projects.domain.networks) /** Project - Api */ implementation(projects.features.send.api) @@ -50,6 +51,7 @@ dependencies { /** Tests */ testImplementation(deps.test.junit) testImplementation(deps.test.coroutine) + testImplementation(deps.test.mockk) testImplementation(tangemDeps.blockchain) { exclude(module = "joda-time") } diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt index 7fe8dfdf64..4d139661e9 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenExchangeAnalyticsEvent.kt @@ -9,7 +9,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM class TokenExchangeAnalyticsEvent( event: String, params: Map = mapOf(), -) : AnalyticsEvent("Token", event, params, null) { +) : AnalyticsEvent("Token", event, params) { class CexTxStatusOpened(token: String) : TokenScreenAnalyticsEvent( event = "Swap Status Opened", diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenOnrampAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenOnrampAnalyticsEvent.kt index 69ba4f794b..ef07a4082d 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenOnrampAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenOnrampAnalyticsEvent.kt @@ -8,7 +8,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM class TokenOnrampAnalyticsEvent( event: String, params: Map = mapOf(), -) : AnalyticsEvent("Onramp", event, params, null) { +) : AnalyticsEvent("Onramp", event, params) { class OnrampStatusOpened(tokenSymbol: String, provider: String, fiatCurrency: String) : TokenScreenAnalyticsEvent( event = "Onramp Status Opened", diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt index 45d43689a8..7de884bf84 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveAnalyticsEvent.kt @@ -6,7 +6,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM sealed class TokenReceiveAnalyticsEvent( event: String, params: Map = mapOf(), -) : AnalyticsEvent("Token / Receive", event, params, null) { +) : AnalyticsEvent("Token / Receive", event, params) { class ReceiveScreenOpened(token: String) : TokenReceiveAnalyticsEvent( event = "Receive Screen Opened", diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt index 9e8a010f52..844923dee0 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenScreenAnalyticsEvent.kt @@ -13,8 +13,7 @@ import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason sealed class TokenScreenAnalyticsEvent( event: String, params: Map = mapOf(), - error: Throwable? = null, -) : AnalyticsEvent("Token", event, params, error) { +) : AnalyticsEvent("Token", event, params) { /** Legacy event. It has a unique category, but it also is sent on TokenScreen */ class DetailsScreenOpened(token: String) : AnalyticsEvent( 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 370a29ee8e..56fc636d4f 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 @@ -4,7 +4,7 @@ import arrow.core.Either import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either -import arrow.core.toNonEmptyListOrNull +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.staking.repositories.StakingRepository import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network @@ -25,22 +25,10 @@ class AddCryptoCurrenciesUseCase( private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, private val quotesRepository: QuotesRepository, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { - /** - * Adds a [currency] to the wallet identified by [userWalletId]. - * - * After successfully adding a currency, it also refreshes the networks for tokens - * that are being added and have corresponding coins in the existing currencies list. - * - * @param userWalletId The ID of the user's wallet. - * @param currency Cryptocurrency to add. - * @return Either an [Throwable] or [Unit] indicating the success of the operation. - */ - suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Either { - return invoke(userWalletId, listOf(currency)) - } - /** * Adds a [cryptoCurrency] token with specific [network] and derivation to the wallet identified by [userWalletId]. * @@ -58,34 +46,32 @@ class AddCryptoCurrenciesUseCase( network: Network, ): Either = either { val tokenToAdd = currenciesRepository.createTokenCurrency(cryptoCurrency = cryptoCurrency, network = network) - invoke(userWalletId = userWalletId, currencies = listOf(tokenToAdd)) + invoke(userWalletId = userWalletId, currency = tokenToAdd) } /** - * Adds a list of [currencies] to the wallet identified by [userWalletId]. + * Adds a [currency] to the wallet identified by [userWalletId]. * - * After successfully adding currencies, it also refreshes the networks for tokens + * After successfully adding a currency, it also refreshes the networks for tokens * that are being added and have corresponding coins in the existing currencies list. * * @param userWalletId The ID of the user's wallet. - * @param currencies The list of cryptocurrencies to add. + * @param currency Cryptocurrency to add. * @return Either an [Throwable] or [Unit] indicating the success of the operation. */ - suspend operator fun invoke( - userWalletId: UserWalletId, - currencies: List, - ): Either = either { - val existingCurrencies = catch({ currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) }) { - raise(it) - } - val currenciesToAdd = currencies - .filterNot(existingCurrencies::contains) - .toNonEmptyListOrNull() - ?: return@either + suspend operator fun invoke(userWalletId: UserWalletId, currency: CryptoCurrency): Either = + either { + val existingCurrencies = catch( + block = { currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) }, + catch = ::raise, + ) + val currencyToAdd = currency.takeUnless(existingCurrencies::contains) ?: return@either - addCurrencies(userWalletId, currenciesToAdd) - refreshUpdatedNetworks(userWalletId, currenciesToAdd, existingCurrencies) - } + addCurrencies(userWalletId, currencyToAdd) + refreshUpdatedNetworks(userWalletId, currencyToAdd, existingCurrencies) + refreshUpdatedYieldBalances(userWalletId, currencyToAdd) + refreshUpdatedQuotes(currencyToAdd) + } suspend operator fun invoke( userWalletId: UserWalletId, @@ -107,10 +93,10 @@ class AddCryptoCurrenciesUseCase( return@either foundToken } val tokenToAdd = createTokenCurrency(userWalletId, contractAddress, networkId) - addCurrencies(userWalletId, listOf(tokenToAdd)) - refreshUpdatedNetworks(userWalletId, listOf(tokenToAdd), existingCurrencies) - refreshUpdatedYieldBalances(userWalletId, existingCurrencies) - refreshUpdatedQuotes(existingCurrencies) + addCurrencies(userWalletId, tokenToAdd) + refreshUpdatedNetworks(userWalletId, tokenToAdd, existingCurrencies) + refreshUpdatedYieldBalances(userWalletId, tokenToAdd) + refreshUpdatedQuotes(tokenToAdd) tokenToAdd } @@ -120,45 +106,52 @@ class AddCryptoCurrenciesUseCase( */ private suspend fun Raise.refreshUpdatedNetworks( userWalletId: UserWalletId, - currenciesToAdd: List, + currencyToAdd: CryptoCurrency, existingCurrencies: List, ) { - val networksToUpdate = currenciesToAdd - .asSequence() - .filterIsInstance() - .filter { hasCoinForToken(existingCurrencies, it) } - .mapTo(hashSetOf(), CryptoCurrency.Token::network) + val networksToUpdate = currencyToAdd.takeIf { currency -> + currency is CryptoCurrency.Token && hasCoinForToken(existingCurrencies, currency) + } + ?.network - val networkToUpdate = currenciesToAdd.map { it.network } - .subtract(existingCurrencies.map { it.network }.toSet()) + val networkToUpdate = currencyToAdd.takeIf { + !existingCurrencies.map(CryptoCurrency::network).contains(it.network) + } + ?.network - catch( - { - networksRepository.getNetworkStatusesSync( + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + multiNetworkStatusFetcher( + MultiNetworkStatusFetcher.Params( userWalletId = userWalletId, - networks = networksToUpdate + networkToUpdate, - refresh = true, - ) - }, - ) { - raise(it) + networks = setOfNotNull(networksToUpdate, networkToUpdate), + ), + ) + } else { + catch( + { + networksRepository.getNetworkStatusesSync( + userWalletId = userWalletId, + networks = setOfNotNull(networksToUpdate, networkToUpdate), + refresh = true, + ) + }, + ) { + raise(it) + } } } - private suspend fun refreshUpdatedYieldBalances( - userWalletId: UserWalletId, - existingCurrencies: List, - ) { - stakingRepository.fetchMultiYieldBalance( + private suspend fun refreshUpdatedYieldBalances(userWalletId: UserWalletId, addedCurrency: CryptoCurrency) { + stakingRepository.fetchSingleYieldBalance( userWalletId = userWalletId, - cryptoCurrencies = existingCurrencies, + cryptoCurrency = addedCurrency, refresh = true, ) } - private suspend fun refreshUpdatedQuotes(addedCurrencies: List) { + private suspend fun refreshUpdatedQuotes(currencyToAdd: CryptoCurrency) { quotesRepository.fetchQuotes( - currenciesIds = addedCurrencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId }, + currenciesIds = setOfNotNull(currencyToAdd.id.rawCurrencyId), refresh = true, ) } @@ -182,9 +175,9 @@ class AddCryptoCurrenciesUseCase( ) } - private suspend fun Raise.addCurrencies(userWalletId: UserWalletId, tokens: List) { + private suspend fun Raise.addCurrencies(userWalletId: UserWalletId, currency: CryptoCurrency) { catch( - { currenciesRepository.addCurrencies(userWalletId, tokens) }, + { currenciesRepository.addCurrencies(userWalletId, listOf(currency)) }, ) { raise(it) } 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 9bac51630e..2098e11d7b 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 @@ -4,6 +4,7 @@ import arrow.core.Either 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.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrency @@ -21,6 +22,8 @@ class FetchCardTokenListUseCase( private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke(userWalletId: UserWalletId, refresh: Boolean = false): Either { @@ -73,10 +76,17 @@ class FetchCardTokenListUseCase( networks: Set, refresh: Boolean, ) { - catch( - block = { networksRepository.getNetworkStatusesSync(userWalletId, networks, refresh) }, - catch = { raise(TokenListError.DataError(it)) }, - ) + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + multiNetworkStatusFetcher( + MultiNetworkStatusFetcher.Params(userWalletId = userWalletId, networks = networks), + ) + .mapLeft { TokenListError.DataError(it) } + } else { + catch( + block = { networksRepository.getNetworkStatusesSync(userWalletId, networks, refresh) }, + catch = { raise(TokenListError.DataError(it)) }, + ) + } } private suspend fun fetchQuotes(currenciesIds: Set, refresh: Boolean) { 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 a6455893a6..fa725b8c75 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 @@ -4,6 +4,7 @@ import arrow.core.Either 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.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrency @@ -31,6 +32,8 @@ class FetchCurrencyStatusUseCase( private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -116,10 +119,17 @@ class FetchCurrencyStatusUseCase( network: Network, refresh: Boolean, ) { - catch( - block = { networksRepository.getNetworkStatusesSync(userWalletId, setOf(network), refresh) }, - ) { - raise(CurrencyStatusError.DataError(it)) + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + singleNetworkStatusFetcher( + params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network), + ) + .mapLeft { CurrencyStatusError.DataError(it) } + } else { + catch( + block = { networksRepository.getNetworkStatusesSync(userWalletId, setOf(network), 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 408c4e81ca..5344fecfd0 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 @@ -6,6 +6,7 @@ import arrow.core.raise.catch 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.staking.repositories.StakingRepository import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrency @@ -33,6 +34,8 @@ class FetchTokenListUseCase( private val networksRepository: NetworksRepository, private val quotesRepository: QuotesRepository, private val stakingRepository: StakingRepository, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -104,10 +107,20 @@ class FetchTokenListUseCase( networks: Set, refresh: Boolean, ) { - catch( - block = { networksRepository.getNetworkStatusesSync(userWalletId, networks, refresh) }, - ) { - raise(TokenListError.DataError(it)) + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + if (refresh) { + multiNetworkStatusFetcher( + params = MultiNetworkStatusFetcher.Params(userWalletId, networks), + ) + .mapLeft { TokenListError.DataError(it) } + .bind() + } + } else { + catch( + block = { networksRepository.getNetworkStatusesSync(userWalletId, networks, refresh) }, + ) { + raise(TokenListError.DataError(it)) + } } } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt index 981a56de13..315c353ae8 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt @@ -1,7 +1,6 @@ package com.tangem.domain.tokens import com.tangem.domain.common.util.cardTypesResolver -import com.tangem.domain.exchange.ExchangeableState import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.StatusSource import com.tangem.domain.promo.PromoRepository @@ -14,7 +13,6 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWallet -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.isNullOrZero import kotlinx.coroutines.flow.* @@ -33,7 +31,6 @@ class GetCryptoCurrencyActionsUseCase( private val stakingRepository: StakingRepository, private val promoRepository: PromoRepository, private val dispatchers: CoroutineDispatcherProvider, - private val swapFeatureToggles: SwapFeatureToggles, private val currencyStatusOperations: BaseCurrencyStatusOperations, ) { @@ -170,12 +167,11 @@ class GetCryptoCurrencyActionsUseCase( } // buy - if (rampManager.availableForBuy(userWallet.scanResponse, userWallet.walletId, cryptoCurrency)) { - activeList.add(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)) + val onrampActionState = getOnrampUnavailabilityReason(userWallet, cryptoCurrencyStatus) + if (onrampActionState.unavailabilityReason == ScenarioUnavailabilityReason.None) { + activeList.add(onrampActionState) } else { - disabledList.add( - TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.BuyUnavailable(cryptoCurrency.symbol)), - ) + disabledList.add(onrampActionState) } // region sell @@ -231,38 +227,37 @@ class GetCryptoCurrencyActionsUseCase( cryptoCurrencyStatus: CryptoCurrencyStatus, requirements: AssetRequirementsCondition?, ): List { - val actionsList = mutableListOf() + val activeList = mutableListOf() + val disabledList = mutableListOf() if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) { - actionsList.add(TokenActionsState.ActionState.CopyAddress(ScenarioUnavailabilityReason.None)) + activeList.add(TokenActionsState.ActionState.CopyAddress(ScenarioUnavailabilityReason.None)) } - if (rampManager.availableForBuy(userWallet.scanResponse, userWallet.walletId, cryptoCurrencyStatus.currency)) { - actionsList.add(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)) + + // buy (is not depend on cache) + val onrampActionState = getOnrampUnavailabilityReason(userWallet, cryptoCurrencyStatus) + if (onrampActionState.unavailabilityReason == ScenarioUnavailabilityReason.None) { + activeList.add(onrampActionState) } else { - actionsList.add( - TokenActionsState.ActionState.Buy( - ScenarioUnavailabilityReason.BuyUnavailable( - cryptoCurrencyName = cryptoCurrencyStatus.currency.name, - ), - ), - ) + disabledList.add(onrampActionState) } - actionsList.add(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.Unreachable)) - actionsList.add( + + disabledList.add(TokenActionsState.ActionState.Send(ScenarioUnavailabilityReason.Unreachable)) + disabledList.add( TokenActionsState.ActionState.Swap( unavailabilityReason = ScenarioUnavailabilityReason.Unreachable, showBadge = false, ), ) - actionsList.add(TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.Unreachable)) + disabledList.add(TokenActionsState.ActionState.Sell(ScenarioUnavailabilityReason.Unreachable)) if (isAddressAvailable(cryptoCurrencyStatus.value.networkAddress)) { val scenario = getReceiveScenario(requirements) - actionsList.add(TokenActionsState.ActionState.Receive(scenario)) + activeList.add(TokenActionsState.ActionState.Receive(scenario)) } - actionsList.add(TokenActionsState.ActionState.Stake(ScenarioUnavailabilityReason.Unreachable, null)) - actionsList.add(TokenActionsState.ActionState.HideToken(ScenarioUnavailabilityReason.None)) + disabledList.add(TokenActionsState.ActionState.Stake(ScenarioUnavailabilityReason.Unreachable, null)) + activeList.add(TokenActionsState.ActionState.HideToken(ScenarioUnavailabilityReason.None)) - return actionsList + return activeList + disabledList } @Suppress("LongMethod") @@ -315,17 +310,12 @@ class GetCryptoCurrencyActionsUseCase( disabledList.add(swapAction) } - // buy - if (rampManager.availableForBuy(userWallet.scanResponse, userWallet.walletId, cryptoCurrency)) { - activeList.add(TokenActionsState.ActionState.Buy(ScenarioUnavailabilityReason.None)) + // buy (is not depend on cache) + val onrampActionState = getOnrampUnavailabilityReason(userWallet, cryptoCurrencyStatus) + if (onrampActionState.unavailabilityReason == ScenarioUnavailabilityReason.None) { + activeList.add(onrampActionState) } else { - disabledList.add( - TokenActionsState.ActionState.Buy( - ScenarioUnavailabilityReason.BuyUnavailable( - cryptoCurrencyName = cryptoCurrencyStatus.currency.name, - ), - ), - ) + disabledList.add(onrampActionState) } // staking @@ -417,23 +407,8 @@ class GetCryptoCurrencyActionsUseCase( showBadge = false, ) } - val exchangeableState = withTimeoutOrNull(REQUEST_EXCHANGE_DATA_TIMEOUT) { - rampManager.availableForSwap(userWallet.walletId, cryptoCurrency) - } ?: ExchangeableState.Loading - val swapStoriesEnabled = swapFeatureToggles.isPromoStoriesEnabled - val currencyName = cryptoCurrency.name - - val reason = when (exchangeableState) { - ExchangeableState.Exchangeable -> { - ScenarioUnavailabilityReason.None - } - ExchangeableState.AssetNotFound -> ScenarioUnavailabilityReason.AssetNotFound(currencyName) - ExchangeableState.Error -> ScenarioUnavailabilityReason.ExpressUnreachable(currencyName) - ExchangeableState.Loading -> ScenarioUnavailabilityReason.ExpressLoading(currencyName) - ExchangeableState.NotExchangeable -> ScenarioUnavailabilityReason.NotExchangeable(currencyName) - } - - val isShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories && swapStoriesEnabled + val reason = rampManager.availableForSwap(userWallet.walletId, cryptoCurrency) + val isShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories TokenActionsState.ActionState.Swap( unavailabilityReason = reason, showBadge = isShowBadge, @@ -446,6 +421,15 @@ class GetCryptoCurrencyActionsUseCase( } } + private suspend fun getOnrampUnavailabilityReason( + userWallet: UserWallet, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): TokenActionsState.ActionState { + val cryptoCurrency = cryptoCurrencyStatus.currency + val reason = rampManager.availableForBuy(userWallet.scanResponse, userWallet.walletId, cryptoCurrency) + return TokenActionsState.ActionState.Buy(unavailabilityReason = reason) + } + private fun isAddressAvailable(networkAddress: NetworkAddress?): Boolean { return networkAddress != null && networkAddress.defaultAddress.value.isNotEmpty() } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt index 4363b74633..199e9ad236 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyUseCase.kt @@ -36,36 +36,6 @@ class GetCryptoCurrencyUseCase( } } - /** - * Returns specific cryptocurrency for a given user wallet. - * - * @param userWalletId The ID of the user's wallet. - * @param id The ID of the cryptocurrency. - * @return An [Either] representing success (Right) or an error (Left) in fetching the status. - */ - suspend operator fun invoke( - userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): Either { - return either { getCurrency(userWalletId, id) } - } - - /** - * Returns specific cryptocurrency for a given user wallet. - * - * !!! Important Use only [CryptoCurrency.ID.value] as cryptoCurrencyId - * - * @param userWalletId The ID of the user's wallet. - * @param cryptoCurrencyId String representation of the [CryptoCurrency.ID.value] of the cryptocurrency. - * @return An [Either] representing success (Right) or an error (Left) in fetching the status. - */ - suspend operator fun invoke( - userWalletId: UserWalletId, - cryptoCurrencyId: String, - ): Either { - return either { getCurrency(userWalletId, cryptoCurrencyId) } - } - /** * Returns the primary cryptocurrency for a given user wallet. * @@ -76,18 +46,6 @@ class GetCryptoCurrencyUseCase( return either { getPrimaryCurrency(userWalletId) } } - private suspend fun Raise.getCurrency( - userWalletId: UserWalletId, - id: CryptoCurrency.ID, - ): CryptoCurrency { - return catch( - block = { - currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId, id) - }, - catch = { raise(CurrencyStatusError.DataError(it)) }, - ) - } - private suspend fun Raise.getCurrency( userWalletId: UserWalletId, id: String, 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 faf7427071..b48aaa3c4d 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 @@ -7,5 +7,5 @@ package com.tangem.domain.tokens */ interface TokensFeatureToggles { - val isBalancesCachingEnabled: Boolean + val isNetworksLoadingRefactoringEnabled: Boolean } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/UpdateDelayedNetworkStatusUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/UpdateDelayedNetworkStatusUseCase.kt index 26d641497a..5be8ae95ef 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/UpdateDelayedNetworkStatusUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/UpdateDelayedNetworkStatusUseCase.kt @@ -4,6 +4,7 @@ import arrow.core.Either 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.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.repository.NetworksRepository @@ -20,6 +21,8 @@ import kotlinx.coroutines.delay class UpdateDelayedNetworkStatusUseCase( private val networksRepository: NetworksRepository, + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { /** @@ -47,10 +50,17 @@ class UpdateDelayedNetworkStatusUseCase( network: Network, refresh: Boolean, ) { - catch( - block = { networksRepository.getNetworkStatusesSync(userWalletId, setOf(network), refresh) }, - ) { - raise(CurrencyStatusError.DataError(it)) + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + singleNetworkStatusFetcher( + params = SingleNetworkStatusFetcher.Params(userWalletId = userWalletId, network = network), + ) + .mapLeft { CurrencyStatusError.DataError(it) } + } else { + catch( + block = { networksRepository.getNetworkStatusesSync(userWalletId, setOf(network), refresh) }, + ) { + raise(CurrencyStatusError.DataError(it)) + } } } } \ 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 f765bd2dec..ac008a8ac8 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 @@ -6,9 +6,14 @@ import arrow.core.raise.catch import arrow.core.raise.either import arrow.core.raise.recover import com.tangem.domain.core.utils.EitherFlow +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.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.* import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -17,7 +22,6 @@ import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.tokens.utils.CurrencyStatusProxyCreator import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch /** * Base operations for working with currency status @@ -29,12 +33,15 @@ import kotlinx.coroutines.launch * [REDACTED_AUTHOR] */ -@Suppress("LargeClass") +@Suppress("LargeClass", "LongParameterList") abstract class BaseCurrencyStatusOperations( private val currenciesRepository: CurrenciesRepository, private val quotesRepository: QuotesRepository, private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, + private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + private val tokensFeatureToggles: TokensFeatureToggles, ) { protected val currencyStatusProxyCreator = CurrencyStatusProxyCreator(stakingRepository) @@ -102,26 +109,13 @@ abstract class BaseCurrencyStatusOperations( val yieldBalanceFlow = getYieldBalance(userWalletId = userWalletId, cryptoCurrency = currency) - return channelFlow { - launch { - fetchComponents( - userWalletId = userWalletId, - networks = nonEmptySetOf(currency.network), - currenciesIds = nonEmptySetOf(currency.id), - currencies = nonEmptyListOf(currency), - ) - } - - combine(quoteFlow, statusFlow, yieldBalanceFlow) { maybeQuote, maybeNetworkStatus, maybeYieldBalance -> - currencyStatusProxyCreator.createCurrencyStatus( - currency = currency, - maybeQuote = maybeQuote, - maybeNetworkStatus = maybeNetworkStatus, - maybeYieldBalance = maybeYieldBalance, - ) - } - .onEach(::send) - .launchIn(this) + return combine(quoteFlow, statusFlow, yieldBalanceFlow) { maybeQuote, maybeNetworkStatus, maybeYieldBalance -> + currencyStatusProxyCreator.createCurrencyStatus( + currency = currency, + maybeQuote = maybeQuote, + maybeNetworkStatus = maybeNetworkStatus, + maybeYieldBalance = maybeYieldBalance, + ) } } @@ -179,12 +173,25 @@ abstract class BaseCurrencyStatusOperations( } val quote = cryptoCurrencyId.rawCurrencyId?.let { quotesRepository.getQuoteSync(it) }?.right() ?: Error.EmptyQuotes.left() - val networkStatuses = + + val networkStatuses = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + singleNetworkStatusSupplier( + params = SingleNetworkStatusProducer.Params( + userWalletId = userWalletId, + network = currency.network, + ), + ) + .firstOrNull() + .right() + } else { networksRepository.getNetworkStatusesSync( userWalletId = userWalletId, networks = setOf(currency.network), refresh = false, - ).firstOrNull { it.network == currency.network }.right() + ) + .firstOrNull { it.network == currency.network } + .right() + } val yieldBalances = getYieldBalanceSync(userWalletId, currency) @@ -231,8 +238,17 @@ abstract class BaseCurrencyStatusOperations( val (networks, currenciesIds) = getIds(nonEmptyCurrencies) val rawIds = currenciesIds.mapNotNull { it.rawCurrencyId }.toSet() val quotes = quotesRepository.getQuotesSync(rawIds, false).right() - val networkStatuses = + + val networkStatuses = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + multiNetworkStatusSupplier( + params = MultiNetworkStatusProducer.Params(userWalletId = userWalletId), + ) + .firstOrNull() + .orEmpty() + .right() + } else { networksRepository.getNetworkStatusesSync(userWalletId, networks, false).right() + } val yieldBalances = getYieldBalancesSync(userWalletId, nonEmptyCurrencies) return currencyStatusProxyCreator.createCurrenciesStatuses( @@ -259,14 +275,27 @@ abstract class BaseCurrencyStatusOperations( }, catch = { Error.DataError(it).left() }, ) - val networkStatus = catch( - block = { - networksRepository.getNetworkStatusesSync(userWalletId, setOf(currency.network)) - .firstOrNull { it.network == currency.network } - .right() - }, - catch = { Error.DataError(it).left() }, - ) + + val networkStatus = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + singleNetworkStatusSupplier( + params = SingleNetworkStatusProducer.Params( + userWalletId = userWalletId, + network = currency.network, + ), + ) + .firstOrNull() + .right() + } else { + catch( + block = { + networksRepository.getNetworkStatusesSync(userWalletId, setOf(currency.network)) + .firstOrNull { it.network == currency.network } + .right() + }, + catch = { Error.DataError(it).left() }, + ) + } + val yieldBalances = getYieldBalanceSync(userWalletId, currency) return currencyStatusProxyCreator.createCurrencyStatus( 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 a14f37e601..3334482cf9 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 @@ -1,6 +1,7 @@ package com.tangem.domain.tokens.operations import arrow.core.* +import arrow.core.raise.either import arrow.core.raise.recover import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.LceFlow @@ -9,9 +10,14 @@ import com.tangem.domain.core.lce.lceFlow import com.tangem.domain.core.utils.EitherFlow import com.tangem.domain.core.utils.lceContent import com.tangem.domain.core.utils.lceError +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.staking.model.stakekit.YieldBalance import com.tangem.domain.staking.model.stakekit.YieldBalanceList import com.tangem.domain.staking.repositories.StakingRepository +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.* import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations.Error @@ -20,16 +26,30 @@ import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.tokens.utils.extractAddress import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +@Suppress("LongParameterList") class CachedCurrenciesStatusesOperations( private val currenciesRepository: CurrenciesRepository, private val quotesRepository: QuotesRepository, private val networksRepository: NetworksRepository, private val stakingRepository: StakingRepository, + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + multiNetworkStatusSupplier: MultiNetworkStatusSupplier, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) : BaseCurrenciesStatusesOperations, - BaseCurrencyStatusOperations(currenciesRepository, quotesRepository, networksRepository, stakingRepository) { + BaseCurrencyStatusOperations( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + stakingRepository = stakingRepository, + multiNetworkStatusSupplier = multiNetworkStatusSupplier, + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + tokensFeatureToggles = tokensFeatureToggles, + ) { override fun getCurrenciesStatuses( userWalletId: UserWalletId, @@ -48,13 +68,12 @@ class CachedCurrenciesStatusesOperations( val prevStatuses = MutableStateFlow(value = emptyList()) val isUpdating = MutableStateFlow(value = true) - val isFetchingStarted = MutableStateFlow(value = false) val nonEmptyCurrencies = currenciesFlow.mapNotNull { it.getOrNull() }.firstOrNull()?.toNonEmptyListOrNull() - if (nonEmptyCurrencies != null) { + if (!isFetchingStarted(userWalletId) && nonEmptyCurrencies != null) { launch { - isFetchingStarted.value = true + setFetchStarted(userWalletId) val (networks, currenciesIds) = getIds(nonEmptyCurrencies) fetchComponents(userWalletId, networks, currenciesIds, nonEmptyCurrencies) @@ -112,9 +131,9 @@ class CachedCurrenciesStatusesOperations( ) } - if (!isFetchingStarted.value) { + if (!isFetchingStarted(userWalletId)) { launch { - isFetchingStarted.value = true + setFetchStarted(userWalletId) fetchComponents(userWalletId, networks, currenciesIds, currencies) } @@ -143,20 +162,26 @@ class CachedCurrenciesStatusesOperations( networks: Set, currenciesIds: Set, currencies: List, - ): Either { - return coroutineScope { - Either.catch { - awaitAll( - async { networksRepository.fetchNetworkStatuses(userWalletId, networks) }, - async { - val rawCurrenciesIds = currenciesIds.mapNotNullTo(mutableSetOf()) { it.rawCurrencyId } - quotesRepository.fetchQuotes(rawCurrenciesIds) - }, - async { stakingRepository.fetchMultiYieldBalance(userWalletId, currencies) }, - ) - } - .map { } + ): Either = either { + coroutineScope { + awaitAll( + async { + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + multiNetworkStatusFetcher( + params = MultiNetworkStatusFetcher.Params(userWalletId, networks), + ) + } else { + networksRepository.fetchNetworkStatuses(userWalletId, networks) + } + }, + async { + val rawCurrenciesIds = currenciesIds.mapNotNullTo(mutableSetOf()) { it.rawCurrencyId } + quotesRepository.fetchQuotes(rawCurrenciesIds) + }, + async { stakingRepository.fetchMultiYieldBalance(userWalletId, currencies) }, + ) } + .map { } } private fun createCurrenciesStatuses( @@ -249,30 +274,44 @@ class CachedCurrenciesStatusesOperations( userWalletId: UserWalletId, network: Network, ): EitherFlow> { - return networksRepository.getNetworkStatusesUpdates(userWalletId, setOf(network)) - .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.isNetworksLoadingRefactoringEnabled) { + singleNetworkStatusSupplier( + params = SingleNetworkStatusProducer.Params(userWalletId = userWalletId, network = network), + ) + .map>> { setOf(it).right() } + .distinctUntilChanged() + .onEmpty { emit(Error.EmptyNetworksStatuses.left()) } + } else { + networksRepository.getNetworkStatusesUpdates(userWalletId, setOf(network)) + .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() + .onEmpty { emit(Error.EmptyNetworksStatuses.left()) } + } } private fun getNetworksStatuses( userWalletId: UserWalletId, networks: NonEmptySet, ): EitherFlow> { - return networksRepository.getNetworkStatusesUpdates(userWalletId, networks) - .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.isNetworksLoadingRefactoringEnabled) { + getNetworkStatusesUpdates(userWalletId, networks) + } else { + networksRepository.getNetworkStatusesUpdates(userWalletId, networks) + .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() + } } private fun getYieldBalances( @@ -290,7 +329,53 @@ class CachedCurrenciesStatusesOperations( .distinctUntilChanged() } + // temporary code because token list is built using networks list + @OptIn(FlowPreview::class) + private fun getNetworkStatusesUpdates( + userWalletId: UserWalletId, + networks: NonEmptySet, + ): EitherFlow> { + return channelFlow { + val state = MutableStateFlow(emptySet()) + + networks.onEach { + launch { + singleNetworkStatusSupplier( + params = SingleNetworkStatusProducer.Params(userWalletId = userWalletId, network = it), + ) + .onEach { status -> + state.update { loadedStatuses -> + loadedStatuses.addOrReplace(status) { it.network == status.network } + } + } + .launchIn(scope = this) + } + } + + state + .onEach(::send) + .launchIn(scope = this) + } + .debounce(timeoutMillis = 500) + .map, Either>> { it.right() } + .distinctUntilChanged() + } + + private fun isFetchingStarted(userWalletId: UserWalletId): Boolean { + return fetchingState.value[userWalletId] ?: false + } + + private fun setFetchStarted(userWalletId: UserWalletId) { + fetchingState.update { + it.toMutableMap().apply { + put(key = userWalletId, value = true) + } + } + } + companion object { internal const val RETRY_DELAY = 2000L + + private val fetchingState = MutableStateFlow(value = emptyMap()) } } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt index eeeb5b4e59..2679e1a047 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/CurrenciesStatusesOperations.kt @@ -1,57 +1,6 @@ package com.tangem.domain.tokens.operations -import arrow.core.Either -import arrow.core.left -import arrow.core.right -import com.tangem.domain.core.utils.EitherFlow -import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.tokens.model.NetworkStatus -import com.tangem.domain.tokens.model.Quote -import com.tangem.domain.tokens.operations.CachedCurrenciesStatusesOperations.Companion.RETRY_DELAY -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.delay -import kotlinx.coroutines.flow.* - -class CurrenciesStatusesOperations( - currenciesRepository: CurrenciesRepository, - private val quotesRepository: QuotesRepository, - private val networksRepository: NetworksRepository, - stakingRepository: StakingRepository, -) : BaseCurrencyStatusOperations(currenciesRepository, quotesRepository, networksRepository, stakingRepository) { - - override fun getQuotes(id: CryptoCurrency.RawID): Flow>> { - return quotesRepository.getQuotesUpdatesLegacy(setOf(id)) - .map, Either>> { quotes -> - if (quotes.isEmpty()) Error.EmptyQuotes.left() else quotes.right() - } - .retryWhen { cause, _ -> - emit(Error.DataError(cause).left()) - delay(RETRY_DELAY) - true - } - } - - override fun getNetworksStatuses( - userWalletId: UserWalletId, - network: Network, - ): EitherFlow> { - return networksRepository.getNetworkStatusesUpdatesLegacy(userWalletId, setOf(network)) - .map, Either>> { it.right() } - .catch { emit(Error.DataError(it).left()) } - .onEmpty { emit(Error.EmptyNetworksStatuses.left()) } - } - - override suspend fun fetchComponents( - userWalletId: UserWalletId, - networks: Set, - currenciesIds: Set, - currencies: List, - ): Either = Unit.right() +class CurrenciesStatusesOperations { sealed class Error { diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/LceCurrenciesStatusesOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/LceCurrenciesStatusesOperations.kt deleted file mode 100644 index 255d2f86b5..0000000000 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/LceCurrenciesStatusesOperations.kt +++ /dev/null @@ -1,224 +0,0 @@ -package com.tangem.domain.tokens.operations - -import arrow.core.* -import arrow.core.raise.ensureNotNull -import arrow.core.raise.recover -import com.tangem.domain.core.lce.Lce -import com.tangem.domain.core.lce.LceFlow -import com.tangem.domain.core.lce.lce -import com.tangem.domain.core.lce.lceFlow -import com.tangem.domain.core.utils.EitherFlow -import com.tangem.domain.staking.model.stakekit.YieldBalance -import com.tangem.domain.staking.model.stakekit.YieldBalanceList -import com.tangem.domain.staking.repositories.StakingRepository -import com.tangem.domain.tokens.error.TokenListError -import com.tangem.domain.tokens.model.* -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.delay -import kotlinx.coroutines.flow.* - -class LceCurrenciesStatusesOperations( - private val currenciesRepository: CurrenciesRepository, - private val quotesRepository: QuotesRepository, - private val networksRepository: NetworksRepository, - private val stakingRepository: StakingRepository, -) : BaseCurrenciesStatusesOperations { - - override fun getCurrenciesStatuses( - userWalletId: UserWalletId, - ): LceFlow> { - return transformToCurrenciesStatuses( - userWalletId = userWalletId, - currenciesFlow = getWalletCurrencies(userWalletId), - ) - } - - private fun transformToCurrenciesStatuses( - userWalletId: UserWalletId, - currenciesFlow: EitherFlow>, - ): LceFlow> = lceFlow { - currenciesFlow.collectLatest { maybeCurrencies -> - val nonEmptyCurrencies = maybeCurrencies.bind().toNonEmptyListOrNull() - ensureNotNull(nonEmptyCurrencies) { TokenListError.EmptyTokens } - - // This is only 'true' when the flow here is empty, such as during initial loading - if (isLoading.get()) { - val loadingCurrencies = createCurrenciesStatuses( - currencies = nonEmptyCurrencies, - maybeNetworkStatuses = null, - maybeQuotes = null, - maybeYieldBalances = null, - ) - send(loadingCurrencies) - } - - val (networks, currenciesIds) = getIds(nonEmptyCurrencies) - - fun createCurrenciesStatuses( - maybeQuotes: Either>?, - maybeNetworkStatuses: Either>?, - maybeYieldBalances: Either?, - ): Lce> = createCurrenciesStatuses( - currencies = nonEmptyCurrencies, - maybeQuotes = maybeQuotes, - maybeNetworkStatuses = maybeNetworkStatuses, - maybeYieldBalances = maybeYieldBalances, - ) - - combine( - getQuotes(currenciesIds), - getNetworksStatuses(userWalletId, networks), - getYieldBalances(userWalletId, nonEmptyCurrencies), - ::createCurrenciesStatuses, - ) - .distinctUntilChanged() - .collectLatest { maybeCurrenciesStatuses -> - send(maybeCurrenciesStatuses) - } - } - } - - private fun getWalletCurrencies(userWalletId: UserWalletId): EitherFlow> { - return currenciesRepository.getWalletCurrenciesUpdates(userWalletId) - .map, Either>> { it.right() } - .catch { emit(TokenListError.DataError(it).left()) } - .distinctUntilChanged() - } - - private fun createCurrenciesStatuses( - currencies: NonEmptyList, - maybeQuotes: Either>?, - maybeNetworkStatuses: Either>?, - maybeYieldBalances: Either?, - ): Lce> = lce { - isLoading.set(maybeNetworkStatuses == null || maybeYieldBalances == null) - - var quotesRetrievingFailed = false - - val networksStatuses = maybeNetworkStatuses?.bindEither()?.toNonEmptySetOrNull() - val yieldBalances = maybeYieldBalances?.bindEither() - val quotes = recover({ maybeQuotes?.bind()?.toNonEmptySetOrNull() }) { - null - } - - if (quotes == null) { - quotesRetrievingFailed = true - } - - currencies.map { currency -> - val quote = quotes?.firstOrNull { it.rawCurrencyId == currency.id.rawCurrencyId } - val networkStatus = networksStatuses?.firstOrNull { it.network == currency.network } - val yieldBalance = findYieldBalanceOrNull(yieldBalances, currency, networkStatus) - - val currencyStatus = createCurrencyStatus( - currency = currency, - quote = quote, - networkStatus = networkStatus, - yieldBalance = yieldBalance, - ignoreQuote = quotesRetrievingFailed, - ) - - if (currencyStatus.value is CryptoCurrencyStatus.Loading) { - isLoading.set(true) - } - - currencyStatus - } - } - - private fun findYieldBalanceOrNull( - yieldBalances: YieldBalanceList?, - currency: CryptoCurrency, - networkStatus: NetworkStatus?, - ): YieldBalance? { - if (yieldBalances !is YieldBalanceList.Data) return null - - val supportedIntegration = stakingRepository.getSupportedIntegrationId(currency.id) - - if (supportedIntegration.isNullOrBlank()) return null - - return yieldBalances.getBalance( - address = extractAddress(networkStatus), - integrationId = supportedIntegration, - ) - } - - private fun createCurrencyStatus( - currency: CryptoCurrency, - quote: Quote?, - networkStatus: NetworkStatus?, - yieldBalance: YieldBalance?, - ignoreQuote: Boolean, - ): CryptoCurrencyStatus { - val currencyStatusOperations = CurrencyStatusOperations( - currency = currency, - quote = quote, - networkStatus = networkStatus, - yieldBalance = yieldBalance, - ignoreQuote = ignoreQuote, - ) - - return currencyStatusOperations.createTokenStatus() - } - - private fun getQuotes(tokensIds: NonEmptySet): Flow>> { - return quotesRepository.getQuotesUpdatesLegacy(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_QUOTES_DELAY) - true - } - .distinctUntilChanged() - } - - private fun getNetworksStatuses( - userWalletId: UserWalletId, - networks: NonEmptySet, - ): EitherFlow> { - return networksRepository.getNetworkStatusesUpdatesLegacy(userWalletId, networks) - .map, Either>> { it.right() } - .catch { emit(TokenListError.DataError(it).left()) } - .distinctUntilChanged() - } - - private fun getYieldBalances( - userWalletId: UserWalletId, - cryptoCurrencies: List, - ): EitherFlow { - return stakingRepository.getMultiYieldBalanceUpdatesLegacy(userWalletId, cryptoCurrencies) - .map> { it.right() } - .catch { emit(TokenListError.DataError(it).left()) } - .distinctUntilChanged() - } - - private fun getIds(currencies: List): Pair, NonEmptySet> { - val currencyIdToNetworkId = currencies.associate { currency -> - currency.id to currency.network - } - val currenciesIds = currencyIdToNetworkId.keys.toNonEmptySetOrNull() - val networks = currencyIdToNetworkId.values.toNonEmptySetOrNull() - - requireNotNull(currenciesIds) { "Currencies IDs cannot be empty" } - requireNotNull(networks) { "Networks IDs cannot be empty" } - - return networks to currenciesIds - } - - private fun extractAddress(networkStatus: NetworkStatus?): String? { - return when (val value = networkStatus?.value) { - is NetworkStatus.NoAccount -> value.address.defaultAddress.value - is NetworkStatus.Unreachable -> value.address?.defaultAddress?.value - is NetworkStatus.Verified -> value.address.defaultAddress.value - else -> null - } - } - - private companion object { - const val RETRY_QUOTES_DELAY = 2000L - } -} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt index 2a655fba4c..1bb9ceb2f4 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListFiatBalanceOperations.kt @@ -4,10 +4,10 @@ import arrow.core.NonEmptyList import com.tangem.domain.models.StatusSource import com.tangem.domain.models.getResultStatusSource import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.TotalFiatBalance import com.tangem.lib.crypto.BlockchainUtils -import com.tangem.lib.crypto.BlockchainUtils.isIncludeStakingTotalBalance import com.tangem.utils.extensions.orZero import java.math.BigDecimal @@ -22,8 +22,7 @@ internal class TokenListFiatBalanceOperations( if (isAnyTokenLoading) return fiatBalance for (token in currencies) { - val networkId = token.currency.network.id.value - val includeStakingBalance = isIncludeStakingTotalBalance(networkId) + val blockchainId = token.currency.network.id.value when (val status = token.value) { is CryptoCurrencyStatus.Loading -> { fiatBalance = TotalFiatBalance.Loading @@ -38,7 +37,7 @@ internal class TokenListFiatBalanceOperations( is CryptoCurrencyStatus.Unreachable, is CryptoCurrencyStatus.NoAmount, -> { - if (BlockchainUtils.isIncludeToBalanceOnError(networkId)) { + if (BlockchainUtils.isIncludeToBalanceOnError(blockchainId)) { fiatBalance = recalculateNoAccountBalance(status, fiatBalance) } else { fiatBalance = TotalFiatBalance.Failed @@ -49,10 +48,10 @@ internal class TokenListFiatBalanceOperations( fiatBalance = recalculateNoAccountBalance(status, fiatBalance) } is CryptoCurrencyStatus.Loaded -> { - fiatBalance = recalculateBalance(status, fiatBalance, includeStakingBalance) + fiatBalance = recalculateBalance(status, fiatBalance, blockchainId) } is CryptoCurrencyStatus.Custom -> { - fiatBalance = recalculateBalance(status, fiatBalance, includeStakingBalance) + fiatBalance = recalculateBalance(status, fiatBalance, blockchainId) } } } @@ -77,16 +76,12 @@ internal class TokenListFiatBalanceOperations( private fun recalculateBalance( status: CryptoCurrencyStatus.Loaded, currentBalance: TotalFiatBalance, - includeStakingBalance: Boolean, + blockchainId: String, ): TotalFiatBalance { return with(currentBalance) { val yieldBalance = status.yieldBalance as? YieldBalance.Data - val stakingBalance = yieldBalance?.getTotalWithRewardsStakingBalance().orZero() - val fiatStakingBalance = if (includeStakingBalance) { - status.fiatRate.times(stakingBalance) - } else { - BigDecimal.ZERO - } + val stakingBalance = yieldBalance?.getTotalWithRewardsStakingBalance(blockchainId).orZero() + val fiatStakingBalance = status.fiatRate.times(stakingBalance) (this as? TotalFiatBalance.Loaded)?.copy( amount = this.amount + status.fiatAmount + fiatStakingBalance, ) ?: TotalFiatBalance.Loaded( @@ -100,16 +95,13 @@ internal class TokenListFiatBalanceOperations( private fun recalculateBalance( status: CryptoCurrencyStatus.Custom, currentBalance: TotalFiatBalance, - includeStakingBalance: Boolean, + blockchainId: String, ): TotalFiatBalance { return with(currentBalance) { val isTokenAmountCanBeSummarized = status.fiatAmount != null - val yieldBalance = (status.yieldBalance as? YieldBalance.Data)?.getTotalWithRewardsStakingBalance().orZero() - val fiatYieldBalance = if (includeStakingBalance) { - status.fiatRate?.times(yieldBalance).orZero() - } else { - BigDecimal.ZERO - } + val yieldBalance = (status.yieldBalance as? YieldBalance.Data) + ?.getTotalWithRewardsStakingBalance(blockchainId).orZero() + val fiatYieldBalance = status.fiatRate?.times(yieldBalance).orZero() (this as? TotalFiatBalance.Loaded)?.copy( amount = this.amount + status.fiatAmount.orZero() + fiatYieldBalance, isAllAmountsSummarized = isTokenAmountCanBeSummarized, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListSortingOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListSortingOperations.kt index fd6ff690a3..f884cd6662 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListSortingOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/operations/TokenListSortingOperations.kt @@ -8,8 +8,8 @@ import arrow.core.raise.ensure import arrow.core.raise.ensureNotNull import arrow.core.toNonEmptyListOrNull import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.domain.tokens.model.* -import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.extensions.orZero import java.math.BigDecimal @@ -99,13 +99,8 @@ internal class TokenListSortingOperations( private fun CryptoCurrencyStatus.getTotalBalance(): BigDecimal { val yieldBalance = value.yieldBalance as? YieldBalance.Data - val totalYieldBalance = yieldBalance?.getTotalWithRewardsStakingBalance().orZero() - val totalFiatYieldBalance = if (BlockchainUtils.isIncludeStakingTotalBalance(currency.network.id.value)) { - totalYieldBalance.multiply(value.fiatRate.orZero()) - } else { - BigDecimal.ZERO - } - + val totalYieldBalance = yieldBalance?.getTotalWithRewardsStakingBalance(currency.network.id.value).orZero() + val totalFiatYieldBalance = totalYieldBalance.multiply(value.fiatRate.orZero()) return value.fiatAmount?.plus(totalFiatYieldBalance).orZero() } diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/NetworksRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/NetworksRepository.kt index 7b799da0f0..1b66ccde06 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/NetworksRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/NetworksRepository.kt @@ -35,17 +35,6 @@ interface NetworksRepository { * */ suspend fun fetchNetworkStatuses(userWalletId: UserWalletId, networks: Set, refresh: Boolean = false) - /** - * Retrieves updates of network statuses of specified blockchain networks for a specific user wallet. - * - * Loads remote network statuses if they have expired. - * - * @param userWalletId The unique identifier of the user wallet. - * @param networks A set of network which statuses are to be retrieved. - * @return A [Flow] emitting a set of [NetworkStatus] objects corresponding to the specified networks. - */ - fun getNetworkStatusesUpdatesLegacy(userWalletId: UserWalletId, networks: Set): Flow> - /** * Fetches pending transactions for given network * 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 c8879e30a3..3e5e99c917 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 @@ -10,13 +10,14 @@ import com.tangem.domain.tokens.mock.MockQuotes import com.tangem.domain.tokens.mock.MockTokens import com.tangem.domain.tokens.mock.MockTokensStates import com.tangem.domain.tokens.model.* -import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations +import com.tangem.domain.tokens.operations.CachedCurrenciesStatusesOperations import com.tangem.domain.tokens.repository.MockCurrenciesRepository import com.tangem.domain.tokens.repository.MockNetworksRepository import com.tangem.domain.tokens.repository.MockQuotesRepository import com.tangem.domain.tokens.repository.MockStakingRepository import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.mockk import junit.framework.TestCase.assertEquals import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -164,7 +165,7 @@ internal class GetPrimaryCurrencyStatusUpdatesUseCaseTest { quotes: Flow>> = flowOf(MockQuotes.quotes.right()), statuses: Flow>> = flowOf(MockNetworks.verifiedNetworksStatuses.right()), ) = GetPrimaryCurrencyStatusUpdatesUseCase( - currencyStatusOperations = CurrenciesStatusesOperations( + currencyStatusOperations = CachedCurrenciesStatusesOperations( currenciesRepository = MockCurrenciesRepository( sortTokensResult = Unit.right(), removeCurrencyResult = removeCurrencyResult, @@ -176,6 +177,13 @@ internal class GetPrimaryCurrencyStatusUpdatesUseCaseTest { quotesRepository = MockQuotesRepository(quotes), networksRepository = MockNetworksRepository(statuses), stakingRepository = MockStakingRepository(), + + tokensFeatureToggles = object : TokensFeatureToggles { + override val isNetworksLoadingRefactoringEnabled: Boolean = false + }, + singleNetworkStatusSupplier = mockk(), + multiNetworkStatusFetcher = mockk(), + multiNetworkStatusSupplier = mockk(), ), dispatchers = dispatchers, ) diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockNetworksRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockNetworksRepository.kt index 956bfa3dac..c7866234bf 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockNetworksRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockNetworksRepository.kt @@ -26,13 +26,6 @@ internal class MockNetworksRepository( /* no-op */ } - override fun getNetworkStatusesUpdatesLegacy( - userWalletId: UserWalletId, - networks: Set, - ): Flow> { - return statuses.map { it.getOrElse { e -> throw e } } - } - override suspend fun fetchNetworkPendingTransactions(userWalletId: UserWalletId, networks: Set) { // no-op } @@ -42,7 +35,7 @@ internal class MockNetworksRepository( networks: Set, refresh: Boolean, ): Set { - return getNetworkStatusesUpdatesLegacy(userWalletId, networks).first() + return statuses.map { it.getOrElse { e -> throw e } }.first() } override fun isNeedToCreateAccountWithoutReserve(network: Network) = false diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt index 2b63da6411..04be38d010 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockStakingRepository.kt @@ -28,7 +28,7 @@ class MockStakingRepository : StakingRepository { override fun getIntegrationKey(cryptoCurrencyId: CryptoCurrency.ID): String = "" - override suspend fun fetchEnabledYields(refresh: Boolean) { + override suspend fun fetchEnabledYields() { /* no-op */ } diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index f798f98dc8..dbae9c5373 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -25,10 +25,12 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.legacy) implementation(projects.libs.blockchainSdk) + implementation(projects.libs.crypto) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) implementation(projects.domain.transaction.models) implementation(projects.domain.demo) implementation(projects.domain.card) + api(projects.domain.networks) } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ValidateAddressError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ValidateAddressError.kt index 75262c24e2..8fc2ee7870 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ValidateAddressError.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ValidateAddressError.kt @@ -1,7 +1,18 @@ package com.tangem.domain.transaction.error -sealed class ValidateAddressError { - data object AddressInWallet : ValidateAddressError() - data object InvalidAddress : ValidateAddressError() - data class DataError(val throwable: Throwable) : ValidateAddressError() +import arrow.core.Either + +typealias AddressValidationResult = Either + +sealed class AddressValidation { + sealed class Success : AddressValidation() { + data object Valid : Success() + data object ValidXAddress : Success() + } + + sealed class Error : AddressValidation() { + data object AddressInWallet : Error() + data object InvalidAddress : Error() + data class DataError(val throwable: Throwable) : Error() + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ValidateMemoError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ValidateMemoError.kt new file mode 100644 index 0000000000..d5d51e0ed2 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/ValidateMemoError.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.transaction.error + +sealed class ValidateMemoError { + data object InvalidMemo : ValidateMemoError() + data class DataError(val throwable: Throwable) : ValidateMemoError() +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt index b910f1c01c..3df20fa08d 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/AssociateAssetUseCase.kt @@ -5,6 +5,9 @@ import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.extensions.SimpleResult import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.networks.single.SingleNetworkStatusProducer +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyAmountStatus import com.tangem.domain.tokens.model.NetworkStatus @@ -14,12 +17,15 @@ import com.tangem.domain.transaction.error.AssociateAssetError import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId import com.tangem.utils.isNullOrZero +import kotlinx.coroutines.flow.firstOrNull class AssociateAssetUseCase( private val cardSdkConfigRepository: CardSdkConfigRepository, private val walletManagersFacade: WalletManagersFacade, private val currenciesRepository: CurrenciesRepository, private val networksRepository: NetworksRepository, + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, + private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( @@ -53,13 +59,25 @@ class AssociateAssetUseCase( } private suspend fun isBalanceZero(userWalletId: UserWalletId, currency: CryptoCurrency): Boolean { - val networkStatus = networksRepository.getNetworkStatusesSync( - userWalletId = userWalletId, - networks = setOf(currency.network), - ).find { it.network == currency.network } + val networkStatus = if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + singleNetworkStatusSupplier( + params = SingleNetworkStatusProducer.Params( + userWalletId = userWalletId, + network = currency.network, + ), + ) + .firstOrNull() + } else { + networksRepository.getNetworkStatusesSync( + userWalletId = userWalletId, + networks = setOf(currency.network), + ).find { it.network == currency.network } + } + val networkCoinAmountStatus = (networkStatus?.value as? NetworkStatus.Verified) ?.amounts ?.get(currency.id) + return networkCoinAmountStatus is CryptoCurrencyAmountStatus.Loaded && networkCoinAmountStatus.value.isNullOrZero() } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt index ba89fcc80f..15efbc7500 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt @@ -12,12 +12,14 @@ import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.network.ResultChecker import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.simple +import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.TapWorkarounds.isStart2Coin import com.tangem.domain.common.TapWorkarounds.isTangemTwins import com.tangem.domain.demo.DemoConfig import com.tangem.domain.demo.DemoTransactionSender -import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.TransactionRepository import com.tangem.domain.transaction.error.SendTransactionError @@ -30,6 +32,8 @@ class SendTransactionUseCase( private val cardSdkConfigRepository: CardSdkConfigRepository, private val transactionRepository: TransactionRepository, private val walletManagersFacade: WalletManagersFacade, + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val tokensFeatureToggles: TokensFeatureToggles, ) { suspend operator fun invoke( txsData: List, @@ -76,10 +80,21 @@ class SendTransactionUseCase( } cardSdkConfigRepository.setLinkedTerminal(linkedTerminal) - return sendResult.fold( - ifRight = { result -> result.hashes.right() }, - ifLeft = { it.left() }, - ) + return sendResult + .onRight { + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + singleNetworkStatusFetcher( + params = SingleNetworkStatusFetcher.Params( + userWalletId = userWallet.walletId, + network = network, + ), + ) + } + } + .fold( + ifRight = { result -> result.hashes.right() }, + ifLeft = { it.left() }, + ) } suspend operator fun invoke( diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCase.kt index 7db3a825f7..026dca2e67 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletAddressUseCase.kt @@ -1,14 +1,16 @@ package com.tangem.domain.transaction.usecase -import arrow.core.Either import arrow.core.left import arrow.core.right +import com.tangem.domain.tokens.model.CryptoCurrencyAddress import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.NetworkAddress import com.tangem.domain.transaction.WalletAddressServiceRepository -import com.tangem.domain.transaction.error.ValidateAddressError +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.lib.crypto.BlockchainUtils /** * Use case for validating wallet address. @@ -23,18 +25,46 @@ class ValidateWalletAddressUseCase( network: Network, address: String, currencyAddress: Set?, - ): Either { + ): AddressValidationResult { + val decodedXAddress = BlockchainUtils.decodeRippleXAddress(address, network.id.value) val isUtxoConsolidationAvailable = walletManagersFacade.checkUtxoConsolidationAvailability(userWalletId, network) - val isCurrentAddress = currencyAddress?.any { it.value == address } ?: true + + val addressToValidate = decodedXAddress?.address ?: address + val isCurrentAddress = currencyAddress?.any { it.value == addressToValidate } ?: true val isForbidSelfSend = isCurrentAddress && !isUtxoConsolidationAvailable - val isValidAddress = walletAddressServiceRepository.validateAddress(userWalletId, network, address) + val isValidAddress = walletAddressServiceRepository.validateAddress(userWalletId, network, addressToValidate) return when { - !isValidAddress -> ValidateAddressError.InvalidAddress.left() - isForbidSelfSend -> ValidateAddressError.AddressInWallet.left() - else -> Unit.right() + !isValidAddress -> AddressValidation.Error.InvalidAddress.left() + isForbidSelfSend -> AddressValidation.Error.AddressInWallet.left() + decodedXAddress != null -> AddressValidation.Success.ValidXAddress.right() + else -> AddressValidation.Success.Valid.right() + } + } + + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + address: String, + senderAddresses: List, + ): AddressValidationResult { + val decodedXAddress = BlockchainUtils.decodeRippleXAddress(address, network.id.value) + val isUtxoConsolidationAvailable = + walletManagersFacade.checkUtxoConsolidationAvailability(userWalletId, network) + + val addressToValidate = decodedXAddress?.address ?: address + val isCurrentAddress = senderAddresses.any { it.address == addressToValidate } + + val isForbidSelfSend = isCurrentAddress && !isUtxoConsolidationAvailable + val isValidAddress = walletAddressServiceRepository.validateAddress(userWalletId, network, addressToValidate) + + return when { + !isValidAddress -> AddressValidation.Error.InvalidAddress.left() + isForbidSelfSend -> AddressValidation.Error.AddressInWallet.left() + decodedXAddress != null -> AddressValidation.Success.ValidXAddress.right() + else -> AddressValidation.Success.Valid.right() } } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletMemoUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletMemoUseCase.kt index d4d5ab22e8..dc7ae1a340 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletMemoUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ValidateWalletMemoUseCase.kt @@ -1,8 +1,11 @@ package com.tangem.domain.transaction.usecase import arrow.core.Either +import arrow.core.left +import arrow.core.right import com.tangem.domain.tokens.model.Network import com.tangem.domain.transaction.WalletAddressServiceRepository +import com.tangem.domain.transaction.error.ValidateMemoError /** * Use case for validating wallet memo. @@ -11,7 +14,16 @@ class ValidateWalletMemoUseCase( private val walletAddressServiceRepository: WalletAddressServiceRepository, ) { - operator fun invoke(network: Network, memo: String): Either = Either.catch { - walletAddressServiceRepository.validateMemo(network, memo) + operator fun invoke(network: Network, memo: String): Either { + return try { + val isValidMemo = walletAddressServiceRepository.validateMemo(network, memo) + if (isValidMemo) { + Unit.right() + } else { + ValidateMemoError.InvalidMemo.left() + } + } catch (ex: Throwable) { + ValidateMemoError.DataError(ex).left() + } } } \ No newline at end of file diff --git a/domain/visa/models/build.gradle.kts b/domain/visa/models/build.gradle.kts index b2df1fba1f..0aafe4291a 100644 --- a/domain/visa/models/build.gradle.kts +++ b/domain/visa/models/build.gradle.kts @@ -10,4 +10,5 @@ dependencies { ksp(deps.moshi.kotlin.codegen) implementation(deps.moshi.adapters) implementation(deps.kotlin.serialization) + implementation(projects.core.error) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt new file mode 100644 index 0000000000..f73861c111 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/error/VisaError.kt @@ -0,0 +1,51 @@ +@file:Suppress("MagicNumber") + +package com.tangem.domain.visa.error + +import com.tangem.core.error.UniversalError + +/** + * Each error code must follow this format: xxxyyyzzz where + * xxx - Feature code + * yyy - Subsystem code + * zzz - Specific error code + * If you need to add new subsystem add it to list below incrementing last code. + * `Subsystems`: + * `001` - Common API + * `002` - First card scan error + * `003` - Activation + * `004` - Authorization API + */ +object VisaError : UniversalError { + override val errorCode: Int = 104000000 +} + +object VisaAPIError : UniversalError { + override val errorCode: Int = 104001000 +} + +enum class VisaCardScanError( + override val errorCode: Int, +) : UniversalError { + FailedToCreateDerivationPath(104002001), + FailedToFindWallet(104002002), + FailedToFindDerivedWalletKey(104002003), +} + +enum class VisaActivationError( + override val errorCode: Int, +) : UniversalError { + BlockedForActivation(104003001), + InvalidActivationState(104003002), + WrongCard(104003003), + WrongRemoteState(104003004), + MissingWallet(104003005), + MissingRootOTP(104003006), + FailedToCreateAddress(104003007), + AddressNotMatched(104003008), + InconsistentRemoteState(104003009), +} + +object VisaAuthorizationAPIError : UniversalError { + override val errorCode: Int = 104004000 +} \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationError.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationError.kt deleted file mode 100644 index ad826a2747..0000000000 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationError.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.domain.visa.model - -enum class VisaActivationError(val message: String) { - BlockedForActivation("Card is blocked for activation"), - InvalidActivationState("Invalid activation state"), - WrongCard("Wrong card tapped"), - WrongRemoteState("Wrong remote state"), - MissingWallet("Missing wallet"), - MissingRootOTP("Missing root OTP"), -} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaCurrency.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaCurrency.kt index b21df662c3..d17b1d726e 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaCurrency.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaCurrency.kt @@ -1,17 +1,22 @@ package com.tangem.domain.visa.model import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.NetworkAddress import org.joda.time.DateTime import java.math.BigDecimal data class VisaCurrency( + val cryptoCurrency: CryptoCurrency, val networkName: String, val symbol: String, val decimals: Int, - val fiatRate: BigDecimal?, + val fiatRate: BigDecimal, + val priceChange: BigDecimal, val fiatCurrency: AppCurrency, val balances: Balances, val limits: Limits, + val paymentAccountAddress: NetworkAddress, ) { data class Balances( diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt b/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt index 1e833b7c16..9664728814 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt @@ -9,7 +9,6 @@ data class VisaTxDetails( val type: String, val status: String, val blockchainAmount: BigDecimal, - val blockchainFee: BigDecimal, val transactionAmount: BigDecimal, val transactionCurrencyCode: Int, val merchantName: String?, @@ -25,7 +24,6 @@ data class VisaTxDetails( val billingAmount: BigDecimal, val billingCurrencyCode: Int, val blockchainAmount: BigDecimal, - val blockchainFee: BigDecimal, val errorCode: Int, val requestDate: DateTime, val requestStatus: String, diff --git a/domain/wallet-connect/build.gradle.kts b/domain/wallet-connect/build.gradle.kts index af72640fab..9b0cbaa2e3 100644 --- a/domain/wallet-connect/build.gradle.kts +++ b/domain/wallet-connect/build.gradle.kts @@ -7,4 +7,8 @@ dependencies { /* Project - Domain */ implementation(projects.domain.core) implementation(projects.domain.wallets.models) + implementation(projects.domain.walletConnect.models) + + /* Other */ + implementation(deps.moshi.adapters) } \ No newline at end of file diff --git a/domain/wallet-connect/models/.gitignore b/domain/wallet-connect/models/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/wallet-connect/models/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/wallet-connect/models/build.gradle.kts b/domain/wallet-connect/models/build.gradle.kts new file mode 100644 index 0000000000..c471057d7c --- /dev/null +++ b/domain/wallet-connect/models/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.ksp) + id("configuration") +} +dependencies { + + /* Domain */ + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) + + /* Other */ + implementation(deps.moshi) + ksp(deps.moshi.kotlin.codegen) +} \ 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 new file mode 100644 index 0000000000..05c3df4d98 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcMethod.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.walletconnect.model + +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 new file mode 100644 index 0000000000..1ef5352c31 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcNetwork.kt @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000000..8f90d64512 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.walletconnect.model + +sealed class WcPairError(override val message: String) : Exception(message) { + + data object UnsupportedDApp : WcPairError("UnsupportedDApp") + data class UnsupportedNetworks( + val chains: Set, + ) : WcPairError("ApprovalErrorMissingNetworks") + + data class ExternalApprovalError( + override val message: String, + ) : WcPairError("ExternalApprovalError") + + data class Unknown(override val message: String) : WcPairError(message) +} \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcRequest.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcRequest.kt new file mode 100644 index 0000000000..256aa09ce1 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.walletconnect.model + +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest + +data class WcRequest( + val rawSdkRequest: WcSdkSessionRequest, + val session: WcSession, + val method: M, +) \ No newline at end of file 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 new file mode 100644 index 0000000000..92501d8bba --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSession.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.walletconnect.model + +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSession +import com.tangem.domain.wallets.models.UserWalletId + +data class WcSession( + val userWalletId: UserWalletId, + 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 new file mode 100644 index 0000000000..06f4291649 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.walletconnect.model + +import com.tangem.domain.wallets.models.UserWalletId + +data class WcSessionApprove( + val walletId: UserWalletId, + val network: List, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt new file mode 100644 index 0000000000..63a946b9c6 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionDTO.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.walletconnect.model + +import com.squareup.moshi.JsonClass +import com.tangem.domain.wallets.models.UserWalletId + +@JsonClass(generateAdapter = true) +data class WcSessionDTO( + val topic: String, + val walletId: UserWalletId, +) \ 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 new file mode 100644 index 0000000000..648163817b --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.walletconnect.model + +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData +import com.tangem.domain.wallets.models.UserWalletId + +data class WcSessionProposal( + val dAppMetaData: WcAppMetaData, + val proposalNetwork: Map, + val securityStatus: Any, +) { + + data class ProposalNetwork( + val walletId: UserWalletId, + val missingRequired: Set, + val required: Set, + val available: Set, + val notAdded: Set, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/Account.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt similarity index 84% rename from app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/Account.kt rename to domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt index 48114a57c4..8c370b9fbd 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/Account.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Account.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.domain.walletconnect2.domain.models +package com.tangem.domain.walletconnect.model.legacy import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/Session.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt similarity index 80% rename from app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/Session.kt rename to domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt index 6950d29486..5ca9f80ed4 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/models/Session.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/Session.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.domain.walletconnect2.domain.models +package com.tangem.domain.walletconnect.model.legacy import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectSessionsRepository.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt similarity index 67% rename from app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectSessionsRepository.kt rename to domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt index 395b928f86..dce7f7f080 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect2/domain/WalletConnectSessionsRepository.kt +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/legacy/WalletConnectSessionsRepository.kt @@ -1,6 +1,4 @@ -package com.tangem.tap.domain.walletconnect2.domain - -import com.tangem.tap.domain.walletconnect2.domain.models.Session +package com.tangem.domain.walletconnect.model.legacy interface WalletConnectSessionsRepository { suspend fun loadSessions(userWallet: String): List diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcAppMetaData.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcAppMetaData.kt new file mode 100644 index 0000000000..f8de996d34 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcAppMetaData.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.walletconnect.model.sdkcopy + +/** + * copy of [com.reown.android.Core.Model.AppMetaData] + */ +data class WcAppMetaData( + val name: String, + val description: String, + val url: String, + val icons: List, + val redirect: String?, + val appLink: String? = null, + val linkMode: Boolean = false, + val verifyUrl: String? = null, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSession.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSession.kt new file mode 100644 index 0000000000..9b2d6c45b7 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSession.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.walletconnect.model.sdkcopy + +/** + * copy of [com.reown.walletkit.client.Wallet.Model.Session] + */ +data class WcSdkSession( + val topic: String, + val appMetaData: WcAppMetaData, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSessionProposal.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSessionProposal.kt new file mode 100644 index 0000000000..93a3734601 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSessionProposal.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.walletconnect.model.sdkcopy + +/** + * copy of [com.reown.walletkit.client.Wallet.Model.SessionProposal] + */ +data class WcSdkSessionProposal( + val name: String, + val description: String, + val url: String, + val proposerPublicKey: String, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSessionRequest.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSessionRequest.kt new file mode 100644 index 0000000000..7b2ed2ff37 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/sdkcopy/WcSdkSessionRequest.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.walletconnect.model.sdkcopy + +/** + * copy of [com.reown.walletkit.client.Wallet.Model.SessionRequest] + */ +data class WcSdkSessionRequest( + val topic: String, + val chainId: String?, + val request: JSONRPCRequest, +) { + + data class JSONRPCRequest( + val id: Long, + val method: String, + val params: String, + ) +} \ 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 new file mode 100644 index 0000000000..6c2a996c91 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/repository/WcSessionsManager.kt @@ -0,0 +1,13 @@ +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 kotlinx.coroutines.flow.Flow + +interface WcSessionsManager { + val sessions: Flow>> + suspend fun saveSession(userWalletId: UserWalletId, session: WcSession) + suspend fun removeSession(userWalletId: UserWalletId, 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 new file mode 100644 index 0000000000..5918accfd9 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/request/WcRequestService.kt @@ -0,0 +1,8 @@ +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/respond/WcRespondService.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/respond/WcRespondService.kt new file mode 100644 index 0000000000..c74ce0e017 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/respond/WcRespondService.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.walletconnect.respond + +import arrow.core.Either +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 +} \ 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 new file mode 100644 index 0000000000..0adbf1dff6 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt @@ -0,0 +1,17 @@ +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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +class WcSessionsUseCase(private val sessionsManager: WcSessionsManager) { + operator fun invoke(): Flow>> { + return sessionsManager.sessions + } + + 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 new file mode 100644 index 0000000000..e0bb5239a4 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSimpleSignUseCase.kt @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000000..a2efe639cd --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt @@ -0,0 +1,3 @@ +package com.tangem.domain.walletconnect.usecase + +interface WcUseCase \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCasesFlowProvider.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCasesFlowProvider.kt new file mode 100644 index 0000000000..d82ecfdb41 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCasesFlowProvider.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.walletconnect.usecase + +import kotlinx.coroutines.flow.Flow + +interface WcUseCasesFlowProvider { + val useCases: Flow +} \ 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 new file mode 100644 index 0000000000..a34bd8e9f6 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/disconnect/WcDisconnectUseCase.kt @@ -0,0 +1,30 @@ +package com.tangem.domain.walletconnect.usecase.disconnect + +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.toList + +class WcDisconnectUseCase( + private val sessionsManager: WcSessionsManager, +) { + + suspend fun disconnectAll() { + sessionsManager.sessions.first() + .flatMap { it.value } + .map { session -> flow { emit(disconnect(session)) } } + .merge() + .toList() + } + + suspend fun disconnect(topic: String) { + val session = sessionsManager.findSessionByTopic(topic) ?: return + disconnect(session) + } + + suspend fun disconnect(session: WcSession) { + sessionsManager.removeSession(session.userWalletId, 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 new file mode 100644 index 0000000000..5e86f84006 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/EthPersonalSignUseCase.kt @@ -0,0 +1,72 @@ +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/WcEthMethod.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMethod.kt new file mode 100644 index 0000000000..cf40981044 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/ethereum/WcEthMethod.kt @@ -0,0 +1,10 @@ +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/initialize/WcInitializeUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/initialize/WcInitializeUseCase.kt new file mode 100644 index 0000000000..71f465a43f --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/initialize/WcInitializeUseCase.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.walletconnect.usecase.initialize + +interface WcInitializeUseCase { + fun init(projectId: String) +} \ 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 new file mode 100644 index 0000000000..07edd2b5f8 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairState.kt @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000000..2de6663dbd --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/pair/WcPairUseCase.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.walletconnect.usecase.pair + +import com.tangem.domain.walletconnect.model.WcSessionApprove +import kotlinx.coroutines.flow.Flow + +interface WcPairUseCase { + + fun pairFlow(uri: String, source: Source): Flow + + fun approve(sessionForApprove: WcSessionApprove) + fun reject() + + enum class Source { QR, DEEPLINK, CLIPBOARD, ETC } +} \ No newline at end of file diff --git a/domain/wallets/build.gradle.kts b/domain/wallets/build.gradle.kts index 616cbdcc21..6ca312b904 100644 --- a/domain/wallets/build.gradle.kts +++ b/domain/wallets/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) id("configuration") } @@ -19,6 +21,7 @@ dependencies { api(projects.domain.core) implementation(projects.domain.legacy) implementation(projects.libs.blockchainSdk) + implementation(projects.libs.tangemSdkApi) implementation(projects.domain.models) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) @@ -29,4 +32,9 @@ dependencies { implementation(tangemDeps.blockchain) // android-library implementation(tangemDeps.card.core) // endregion + + // region DI + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + // end } \ No newline at end of file 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 ffa69e47ae..dfaec8126c 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 @@ -3,14 +3,17 @@ package com.tangem.domain.wallets.builder import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase +import com.tangem.domain.wallets.usecase.GetCardImageUseCase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject -class UserWalletBuilder( - private val scanResponse: ScanResponse, +class UserWalletBuilder @AssistedInject constructor( + @Assisted private val scanResponse: ScanResponse, private val generateWalletNameUseCase: GenerateWalletNameUseCase, - private val getCardImageUseCase: GetCardImageUseCase = GetCardImageUseCase(), + private val getCardImageUseCase: GetCardImageUseCase, ) { private var backupCardsIds: Set = emptySet() private var hasBackupError: Boolean = false @@ -56,4 +59,10 @@ class UserWalletBuilder( } } } + + @AssistedFactory + interface Factory { + + fun create(scanResponse: ScanResponse): UserWalletBuilder + } } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index b1e526ce5c..6ec22a4eaf 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -32,6 +32,8 @@ interface WalletsRepository { fun nftEnabledStatus(userWalletId: UserWalletId): Flow + fun nftEnabledStatuses(): Flow> + suspend fun enableNFT(userWalletId: UserWalletId) suspend fun disableNFT(userWalletId: UserWalletId) 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 67080c0682..b44e440301 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 @@ -6,16 +6,15 @@ import com.tangem.domain.common.TwinCardNumber import com.tangem.domain.common.TwinsHelper import com.tangem.domain.wallets.models.Artwork import com.tangem.operations.attestation.OnlineCardVerifier -import com.tangem.operations.attestation.TangemApi /** * Use case for getting card image url * - * @property verifier REST API + * @property verifier online card verifier * [REDACTED_AUTHOR] */ -class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardVerifier()) { +class GetCardImageUseCase(private val verifier: OnlineCardVerifier) { /** * Get card image url @@ -30,7 +29,11 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardV if (artworkId.isNullOrEmpty()) { getFallbackArtworkUrl(cardId) } else { - getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId) + OnlineCardVerifier.getUrlForArtwork( + cardId = cardId, + cardPublicKey = cardPublicKey.toHexString(), + artworkId = artworkId, + ) } } @@ -51,9 +54,4 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardV } } } - - private fun getUrlForArtwork(cardId: String, cardPublicKeyHex: String, artworkId: String): String { - return TangemApi.Companion.BaseUrl.VERIFY.url + TangemApi.ARTWORK + - "?artworkId=$artworkId&CID=$cardId&publicKey=$cardPublicKeyHex" - } } \ No newline at end of file diff --git a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/DefaultAskBiometryComponent.kt b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/DefaultAskBiometryComponent.kt index f76042e8fb..81ca241043 100644 --- a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/DefaultAskBiometryComponent.kt +++ b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/DefaultAskBiometryComponent.kt @@ -9,7 +9,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.essenty.instancekeeper.getOrCreateSimple import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.features.biometry.AskBiometryComponent diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt index 6badebac2d..570693940c 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt @@ -23,7 +23,6 @@ import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.models.SaveWalletError import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsSyncUseCase import com.tangem.features.details.impl.R @@ -38,7 +37,7 @@ import kotlin.coroutines.resume internal class UserWalletSaver @Inject constructor( private val scanCardProcessor: ScanCardProcessor, private val saveWalletUseCase: SaveWalletUseCase, - private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, private val shouldSaveUserWalletsSyncUseCase: ShouldSaveUserWalletsSyncUseCase, private val reduxStateHolder: ReduxStateHolder, private val messageSender: UiMessageSender, @@ -56,17 +55,20 @@ internal class UserWalletSaver @Inject constructor( } }, ifRight = { scanResponse -> - recover(block = { - scanResponse ?: return - val userWallet = createUserWallet(scanResponse) - saveWallet(userWallet) - }, recover = { + recover( + block = { + scanResponse ?: return + val userWallet = createUserWallet(scanResponse) + saveWallet(userWallet) + }, + recover = { val message = it.message if (!message.isNullOrEmpty()) { messageSender.send(SnackbarMessage(message)) } - },) + }, + ) }, ) } @@ -110,7 +112,7 @@ internal class UserWalletSaver @Inject constructor( } private suspend fun Raise.createUserWallet(response: ScanResponse): UserWallet { - val userWallet = UserWalletBuilder(response, generateWalletNameUseCase).build() + val userWallet = userWalletBuilderFactory.create(scanResponse = response).build() return ensureNotNull(userWallet) { Error.Unknown } } diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt index edceddb46d..822eb5efd4 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultAddCustomTokenComponent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.DelicateDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -13,6 +14,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.tokens.model.Network import com.tangem.features.managetokens.analytics.CustomTokenAnalyticsEvent import com.tangem.features.managetokens.component.AddCustomTokenComponent import com.tangem.features.managetokens.component.CustomTokenFormComponent @@ -171,6 +173,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( showForm(derivationPath = derivationPath) } + @OptIn(DelicateDecomposeApi::class) private fun showDerivationPathSelector(formValues: CustomTokenFormValues) { val currentConfig = contentStack.value.active.configuration @@ -181,6 +184,7 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( navigation.push(config) } + @OptIn(DelicateDecomposeApi::class) private fun showNetworkSelector(formValues: CustomTokenFormValues) { val currentConfig = contentStack.value.active.configuration @@ -194,14 +198,35 @@ internal class DefaultAddCustomTokenComponent @AssistedInject constructor( private fun showForm(network: SelectedNetwork? = null, derivationPath: SelectedDerivationPath? = null) { val currentConfig = contentStack.value.active.configuration + val selectedNetwork = network ?: currentConfig.selectedNetwork + val selectedDerivationPath = derivationPath ?: currentConfig.selectedDerivationPath + val config = currentConfig.copy( step = AddCustomTokenConfig.Step.FORM, - selectedNetwork = network ?: currentConfig.selectedNetwork, - selectedDerivationPath = derivationPath ?: currentConfig.selectedDerivationPath, + selectedNetwork = selectedNetwork, + selectedDerivationPath = defineSelectedDerivationPath(selectedNetwork, selectedDerivationPath), ) + navigation.replaceAll(config) } + private fun defineSelectedDerivationPath( + selectedNetwork: SelectedNetwork?, + selectedDerivationPath: SelectedDerivationPath?, + ): SelectedDerivationPath? { + // if default derivation path + if (selectedDerivationPath?.value == selectedNetwork?.derivationPath) return null + + // map to custom derivation path + return if (selectedDerivationPath != null && selectedDerivationPath.value.value != null) { + selectedDerivationPath.copy( + value = Network.DerivationPath.Custom(value = requireNotNull(selectedDerivationPath.value.value)), + ) + } else { + null + } + } + private fun dismissAndNotify() { dismiss() params.onCurrencyAdded() diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt index 80ed6c4a56..c7d87cd784 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/ui/AddCustomTokenBottomSheet.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt index 7dde531506..d9052b18b2 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensListManager.kt @@ -1,7 +1,5 @@ package com.tangem.features.managetokens.utils.list -import androidx.compose.ui.util.fastForEachIndexed -import androidx.compose.ui.util.fastMap import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -10,10 +8,7 @@ import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase -import com.tangem.domain.managetokens.CheckHasLinkedTokensUseCase -import com.tangem.domain.managetokens.GetManagedTokensUseCase -import com.tangem.domain.managetokens.RemoveCustomManagedCryptoCurrencyUseCase +import com.tangem.domain.managetokens.* import com.tangem.domain.managetokens.model.* import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId @@ -43,6 +38,7 @@ import javax.inject.Inject @ModelScoped internal class ManageTokensListManager @Inject constructor( private val getManagedTokensUseCase: GetManagedTokensUseCase, + private val getDistinctManagedTokensUseCase: GetDistinctManagedCurrenciesUseCase, private val checkHasLinkedTokensUseCase: CheckHasLinkedTokensUseCase, private val removeCustomCurrencyUseCase: RemoveCustomManagedCryptoCurrencyUseCase, private val checkCurrencyUnsupportedUseCase: CheckCurrencyUnsupportedUseCase, @@ -166,47 +162,27 @@ internal class ManageTokensListManager @Inject constructor( return } - state.update { state -> - val newBatches = distinctCurrencies(batchListState.data) - val currentBatches = state.currencyBatches + scope.launch { + state.update { state -> + val newBatches = getDistinctManagedTokensUseCase(batchListState.data) + val currentBatches = state.currencyBatches - // Distinct until changed - if (newBatches.size == currentBatches.size && - newBatches.map { it.key } == currentBatches.map { it.key } && - newBatches.flatMap { it.data } == currentBatches.flatMap { it.data } - ) { - return - } - - val canEditItems = userWalletId != null - state.copy( - userWalletId = userWalletId, - currencyBatches = newBatches, - uiBatches = uiManager.createOrUpdateUiBatches(newBatches, canEditItems), - canEditItems = canEditItems, - ) - } - } - - // FIXME: Add interception functionality to BatchFlow state and do this on domain - // [REDACTED_JIRA] - private fun distinctCurrencies( - batches: List>>, - ): List>> { - val allCurrenciesIds = mutableListOf() - - return batches.fastMap { batch -> - val batchCurrencies = batch.data.toMutableList() - - batch.data.fastForEachIndexed { index, currency -> - if (currency.id in allCurrenciesIds) { - batchCurrencies.removeAt(index) - } else { - allCurrenciesIds.add(currency.id) + // Distinct until changed + if (newBatches.size == currentBatches.size && + newBatches.map { it.key } == currentBatches.map { it.key } && + newBatches.flatMap { it.data } == currentBatches.flatMap { it.data } + ) { + return@launch } - } - batch.copy(data = batchCurrencies) + val canEditItems = userWalletId != null + state.copy( + userWalletId = userWalletId, + currencyBatches = newBatches, + uiBatches = uiManager.createOrUpdateUiBatches(newBatches, canEditItems), + canEditItems = canEditItems, + ) + } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/ExchangesBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/ExchangesBottomSheet.kt index 4c7bd46d6c..8135d641d2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/ExchangesBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/ExchangesBottomSheet.kt @@ -19,7 +19,7 @@ import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.audits.AuditLabelUM -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt index 23e16539b1..8081d1a0b8 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/InfoBottomSheet.kt @@ -7,9 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt index 39970e9671..076b704bb8 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -16,7 +16,7 @@ import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.common.ui.charts.state.rememberMarketChartState import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM -import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData +import com.tangem.core.ui.components.UnableToLoadData @Composable internal fun MarketTokenDetailsChart( diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt index 391e6b8969..e6ef8e94b6 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/SecurityScoreBottomSheet.kt @@ -23,9 +23,9 @@ import androidx.compose.ui.util.fastForEachIndexed import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import com.tangem.core.ui.components.* -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle import com.tangem.core.ui.components.inputrow.inner.DividerContainer import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.resolveReference diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt index 0d04d56bbe..b796c0f38a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/TokenMarketDetailsBody.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.state.MarketsTokenDetailsUM -import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData +import com.tangem.core.ui.components.UnableToLoadData internal fun LazyListScope.tokenMarketDetailsBody( state: MarketsTokenDetailsUM.Body, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt index 663a22a12f..02e1ba82e2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/model/TokenActionsHandler.kt @@ -1,13 +1,14 @@ package com.tangem.features.markets.portfolio.impl.model import com.tangem.common.routing.AppRoute +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.SnackbarMessage @@ -41,6 +42,7 @@ internal class TokenActionsHandler @AssistedInject constructor( private val isDemoCardUseCase: IsDemoCardUseCase, private val messageSender: UiMessageSender, private val onrampFeatureToggles: OnrampFeatureToggles, + private val shareManager: ShareManager, ) { private val disabledActionsInDemoMode = buildSet { @@ -102,15 +104,15 @@ internal class TokenActionsHandler @AssistedInject constructor( } }, content = TokenReceiveBottomSheetConfig( - name = currency.name, - symbol = currency.symbol, - network = currency.network.name, - addresses = networkAddress.availableAddresses - .mapToAddressModels(currency) - .toImmutableList(), + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = networkAddress, showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, - onCopyClick = {}, - onShareClick = {}, + onCopyClick = { clipboardManager.setText(networkAddress.defaultAddress.value, isSensitive = true) }, + onShareClick = { shareManager.shareText(networkAddress.defaultAddress.value) }, ), ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt index 262b9fca39..fdfbb03db2 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/AddToPortfolioBottomSheet.kt @@ -26,7 +26,7 @@ import com.tangem.common.ui.userwallet.UserWalletItem import com.tangem.core.ui.components.* import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.block.information.InformationBlock -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.buttons.common.TangemButton import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt index 811b350c65..615366fd0e 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/MyPortfolio.kt @@ -18,7 +18,7 @@ import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.SmallButtonShimmer import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/TokenActionsBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/TokenActionsBottomSheet.kt index 8a09c1758a..8cb81049e5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/TokenActionsBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/TokenActionsBottomSheet.kt @@ -9,9 +9,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.SimpleSettingsRow -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WalletSelectorBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WalletSelectorBottomSheet.kt index a9fdbdaef9..615c95ba9f 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WalletSelectorBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/WalletSelectorBottomSheet.kt @@ -18,7 +18,7 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBarHeight import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.TangemBlockCardColors -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt index 41fc02dedd..c4adb37979 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt @@ -134,7 +134,7 @@ internal class MarketsTokenItemConverter( TrendInterval.D7 -> tokenQuotesShort.weekChangePercent TrendInterval.M1 -> tokenQuotesShort.monthChangePercent } - val scaled = percent.setScale(4, RoundingMode.HALF_UP) + val scaled = percent?.setScale(4, RoundingMode.HALF_UP) return when { scaled == null -> PriceChangeType.NEUTRAL scaled > BigDecimal.ZERO -> PriceChangeType.UP diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index 703e2eae67..6fcc3b1195 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.buttons.SecondarySmallButton import com.tangem.core.ui.components.buttons.SmallButtonConfig import com.tangem.core.ui.components.list.InfiniteListHandler diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt index 9f08d614c4..d862db0b01 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListSortByBottomSheet.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.inputrow.InputRowChecked import com.tangem.core.ui.components.inputrow.inner.DividerContainer diff --git a/features/nft/api/build.gradle.kts b/features/nft/api/build.gradle.kts index f94a0582c0..d9217b0692 100644 --- a/features/nft/api/build.gradle.kts +++ b/features/nft/api/build.gradle.kts @@ -12,6 +12,8 @@ dependencies { /* Project - Domain */ implementation(projects.domain.models) + implementation(projects.domain.nft.models) + implementation(projects.domain.wallets.models) /* Project - Core */ implementation(projects.core.decompose) diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt new file mode 100644 index 0000000000..3417b6a1f2 --- /dev/null +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTCollectionsComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.nft.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.wallets.models.UserWalletId + +interface NFTCollectionsComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt new file mode 100644 index 0000000000..0f43e3a58f --- /dev/null +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt @@ -0,0 +1,16 @@ +package com.tangem.features.nft.component + +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 NFTDetailsComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + ) + + 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/NFTReceiveComponent.kt new file mode 100644 index 0000000000..9184afa340 --- /dev/null +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTReceiveComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.nft.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.wallets.models.UserWalletId + +interface NFTReceiveComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index 40d3b149c9..a33b3e1ad5 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -26,6 +26,12 @@ dependencies { implementation(projects.core.navigation) implementation(projects.core.datasource) + /** Domain modules */ + implementation(projects.domain.nft) + implementation(projects.domain.nft.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets.models) + /** Common */ implementation(projects.common.ui) implementation(projects.common.routing) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt index b456b29b0b..75f083f217 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt @@ -1,10 +1,23 @@ package com.tangem.features.nft import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.decompose.model.Model +import com.tangem.features.nft.collections.DefaultNFTCollectionsComponent +import com.tangem.features.nft.collections.model.NFTCollectionsModel +import com.tangem.features.nft.component.NFTCollectionsComponent +import com.tangem.features.nft.component.NFTDetailsComponent +import com.tangem.features.nft.component.NFTReceiveComponent +import com.tangem.features.nft.details.DefaultNFTDetailsComponent +import com.tangem.features.nft.details.model.NFTDetailsModel +import com.tangem.features.nft.receive.DefaultNFTReceiveComponent +import com.tangem.features.nft.receive.model.NFTReceiveModel +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap import javax.inject.Singleton @Module @@ -16,4 +29,37 @@ internal object NFTFeatureModule { fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): NFTFeatureToggles { return DefaultNFTFeatureToggles(featureTogglesManager) } +} + +@Module +@InstallIn(SingletonComponent::class) +internal interface NFTFeatureModuleBinds { + @Binds + @Singleton + fun bindNFTCollectionsComponentFactory( + impl: DefaultNFTCollectionsComponent.Factory, + ): NFTCollectionsComponent.Factory + + @Binds + @IntoMap + @ClassKey(NFTCollectionsModel::class) + fun bindNFTCollectionsModel(model: NFTCollectionsModel): Model + + @Binds + @Singleton + fun bindNFTReceiveComponentFactory(impl: DefaultNFTReceiveComponent.Factory): NFTReceiveComponent.Factory + + @Binds + @IntoMap + @ClassKey(NFTReceiveModel::class) + fun bindNFTReceiveModel(model: NFTReceiveModel): Model + + @Binds + @Singleton + fun bindNFTDetailsComponentFactory(impl: DefaultNFTDetailsComponent.Factory): NFTDetailsComponent.Factory + + @Binds + @IntoMap + @ClassKey(NFTDetailsModel::class) + fun bindNFTDetailsModel(model: NFTDetailsModel): Model } \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt new file mode 100644 index 0000000000..bb82591e48 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/DefaultNFTCollectionsComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.nft.collections + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.nft.collections.model.NFTCollectionsModel +import com.tangem.features.nft.collections.ui.NFTCollections +import com.tangem.features.nft.component.NFTCollectionsComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNFTCollectionsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NFTCollectionsComponent.Params, +) : NFTCollectionsComponent, AppComponentContext by context { + + private val model: NFTCollectionsModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + NFTCollections(state, modifier) + } + + @AssistedFactory + interface Factory : NFTCollectionsComponent.Factory { + override fun create( + context: AppComponentContext, + params: NFTCollectionsComponent.Params, + ): DefaultNFTCollectionsComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetUM.kt new file mode 100644 index 0000000000..fc75d29651 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.nft.collections.entity + +internal data class NFTCollectionAssetUM( + val id: String, + val name: String, + val imageUrl: String?, + val price: NFTSalePriceUM, + val onItemClick: () -> Unit, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetsListUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetsListUM.kt new file mode 100644 index 0000000000..742ae7adce --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionAssetsListUM.kt @@ -0,0 +1,12 @@ +package com.tangem.features.nft.collections.entity + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class NFTCollectionAssetsListUM { + data object Init : NFTCollectionAssetsListUM() + data class Loading(val itemsCount: Int) : NFTCollectionAssetsListUM() + data class Failed(val onRetryClick: () -> Unit) : NFTCollectionAssetsListUM() + data class Content(val items: ImmutableList) : NFTCollectionAssetsListUM() +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt new file mode 100644 index 0000000000..e7e24ded3b --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionUM.kt @@ -0,0 +1,15 @@ +package com.tangem.features.nft.collections.entity + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.TextReference + +internal data class NFTCollectionUM( + val id: String, + val name: String, + @DrawableRes val networkIconId: Int, + val logoUrl: String?, + val description: TextReference, + val assets: NFTCollectionAssetsListUM, + val isExpanded: Boolean, + val onExpandClick: () -> Unit, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt new file mode 100644 index 0000000000..14267e3120 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsStateUM.kt @@ -0,0 +1,6 @@ +package com.tangem.features.nft.collections.entity + +internal data class NFTCollectionsStateUM( + val onBackClick: () -> Unit, + val content: NFTCollectionsUM, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt new file mode 100644 index 0000000000..394144b65d --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsUM.kt @@ -0,0 +1,29 @@ +package com.tangem.features.nft.collections.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class NFTCollectionsUM { + + data class Empty( + val onReceiveClick: () -> Unit, + ) : NFTCollectionsUM() + + data class Loading( + val onReceiveClick: () -> Unit, + ) : NFTCollectionsUM() + + data class Failed( + val onRetryClick: () -> Unit, + val onReceiveClick: () -> Unit, + ) : NFTCollectionsUM() + + data class Content( + val search: SearchBarUM, + val collections: ImmutableList, + val warnings: ImmutableList, + val onReceiveClick: () -> Unit, + ) : NFTCollectionsUM() +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsWarningUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsWarningUM.kt new file mode 100644 index 0000000000..c2f3e9ab11 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTCollectionsWarningUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.nft.collections.entity + +import com.tangem.core.ui.components.notifications.NotificationConfig + +internal data class NFTCollectionsWarningUM( + val id: String, + val config: NotificationConfig, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTSalePriceUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTSalePriceUM.kt new file mode 100644 index 0000000000..8747a49f55 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/NFTSalePriceUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.nft.collections.entity + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed class NFTSalePriceUM { + data object Loading : NFTSalePriceUM() + data object Failed : NFTSalePriceUM() + data class Content(val price: String) : NFTSalePriceUM() +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt new file mode 100644 index 0000000000..867c3d2303 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ChangeCollectionExpandedStateTransformer.kt @@ -0,0 +1,34 @@ +package com.tangem.features.nft.collections.entity.transformer + +import com.tangem.domain.nft.models.NFTCollection +import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM +import com.tangem.features.nft.collections.entity.NFTCollectionsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class ChangeCollectionExpandedStateTransformer( + private val collectionId: NFTCollection.Identifier, + private val onFirstExpanded: () -> Unit, +) : Transformer { + + override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy( + content = when (prevState.content) { + is NFTCollectionsUM.Empty, + is NFTCollectionsUM.Loading, + is NFTCollectionsUM.Failed, + -> prevState.content + is NFTCollectionsUM.Content -> prevState.content.copy( + collections = prevState.content.collections.map { + if (it.id == collectionId.toString()) { + if (!it.isExpanded) { + onFirstExpanded() + } + it.copy(isExpanded = !it.isExpanded) + } else { + it + } + }.toPersistentList(), + ) + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ToggleSearchBarTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ToggleSearchBarTransformer.kt new file mode 100644 index 0000000000..12445f43ce --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/ToggleSearchBarTransformer.kt @@ -0,0 +1,22 @@ +package com.tangem.features.nft.collections.entity.transformer + +import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM +import com.tangem.features.nft.collections.entity.NFTCollectionsUM +import com.tangem.utils.transformer.Transformer + +internal class ToggleSearchBarTransformer(private val isActive: Boolean) : Transformer { + + override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy( + content = when (val content = prevState.content) { + is NFTCollectionsUM.Content -> content.copy( + search = content.search.copy( + isActive = isActive, + ), + ) + is NFTCollectionsUM.Empty, + is NFTCollectionsUM.Loading, + is NFTCollectionsUM.Failed, + -> content + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt new file mode 100644 index 0000000000..e5fe0846c9 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateDataStateTransformer.kt @@ -0,0 +1,134 @@ +package com.tangem.features.nft.collections.entity.transformer + +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.nft.models.* +import com.tangem.features.nft.collections.entity.* +import com.tangem.features.nft.impl.R +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList + +@Suppress("LongParameterList") +internal class UpdateDataStateTransformer( + private val nftCollections: List, + private val searchQuery: String, + private val onReceiveClick: () -> Unit, + private val onRetryClick: () -> Unit, + private val onExpandCollectionClick: (NFTCollection) -> Unit, + private val onRetryAssetsClick: (NFTCollection) -> Unit, + private val onAssetClick: (NFTAsset) -> Unit, + private val initialSearchBarFactory: () -> SearchBarUM, +) : Transformer { + + override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy( + content = when { + nftCollections.allCollectionsFailed() -> + NFTCollectionsUM.Failed(onRetryClick, onReceiveClick) + nftCollections.anyCollectionFailed() && nftCollections.allLoadedCollectionsEmpty() -> + NFTCollectionsUM.Failed(onRetryClick, onReceiveClick) + nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() -> + NFTCollectionsUM.Empty(onReceiveClick) + !nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() -> + NFTCollectionsUM.Loading(onReceiveClick) + else -> { + NFTCollectionsUM.Content( + search = if (prevState.content is NFTCollectionsUM.Content) { + prevState.content.search.copy( + query = searchQuery, + ) + } else { + initialSearchBarFactory() + }, + collections = nftCollections + .map { it.content } + .asSequence() + .filterIsInstance() + .map { it.collections.orEmpty().transform(prevState, searchQuery) } + .flatten() + .toPersistentList(), + warnings = transformNotifications(), + onReceiveClick = onReceiveClick, + ) + } + }, + ) + + private fun List.transform( + state: NFTCollectionsStateUM, + query: String, + ): ImmutableList = mapNotNull { + if (query.isEmpty() || it.name?.lowercase()?.contains(query.lowercase()) == true) { + NFTCollectionUM( + id = it.id.toString(), + networkIconId = getActiveIconRes(it.network.id.value), + name = it.name.orEmpty(), + description = TextReference.PluralRes( + R.plurals.nft_collections_count, + it.count, + wrappedList(it.count), + ), + logoUrl = it.logoUrl, + assets = it.transformAssets(), + onExpandClick = { + onExpandCollectionClick(it) + }, + isExpanded = it.isExpanded(state), + ) + } else { + null + } + }.toPersistentList() + + private fun transformNotifications(): ImmutableList = buildList { + if (nftCollections.anyCollectionFailed()) { + add( + NFTCollectionsWarningUM( + id = "loading troubles", + config = NotificationConfig( + title = TextReference.Res(R.string.nft_collections_warning_title), + subtitle = TextReference.Res(R.string.nft_collections_warning_subtitle), + iconResId = R.drawable.ic_alert_triangle_20, + ), + ), + ) + } + }.toPersistentList() + + private fun NFTCollection.transformAssets(): NFTCollectionAssetsListUM = when (val assets = this.assets) { + is NFTCollection.Assets.Empty -> NFTCollectionAssetsListUM.Init + is NFTCollection.Assets.Loading -> NFTCollectionAssetsListUM.Loading(count) + is NFTCollection.Assets.Failed -> NFTCollectionAssetsListUM.Failed { onRetryAssetsClick(this) } + is NFTCollection.Assets.Value -> NFTCollectionAssetsListUM.Content( + items = assets + .items + .map { it.transform() } + .toPersistentList(), + ) + } + + private fun NFTAsset.transform(): NFTCollectionAssetUM = NFTCollectionAssetUM( + id = id.toString(), + name = name.orEmpty(), + imageUrl = media?.url, + price = when (val salePrice = salePrice) { + is NFTSalePrice.Empty -> NFTSalePriceUM.Failed + is NFTSalePrice.Loading -> NFTSalePriceUM.Loading + is NFTSalePrice.Error -> NFTSalePriceUM.Failed + is NFTSalePrice.Value -> NFTSalePriceUM.Content(salePrice.value.toString()) + }, + onItemClick = { + onAssetClick(this) + }, + ) + + private fun NFTCollection.isExpanded(state: NFTCollectionsStateUM): Boolean = + (state.content as? NFTCollectionsUM.Content) + ?.collections + ?.firstOrNull { it.id == id.toString() } + ?.isExpanded + ?: false +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateSearchQueryTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateSearchQueryTransformer.kt new file mode 100644 index 0000000000..e2dd4173fa --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/entity/transformer/UpdateSearchQueryTransformer.kt @@ -0,0 +1,22 @@ +package com.tangem.features.nft.collections.entity.transformer + +import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM +import com.tangem.features.nft.collections.entity.NFTCollectionsUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateSearchQueryTransformer(private val newQuery: String) : Transformer { + + override fun transform(prevState: NFTCollectionsStateUM): NFTCollectionsStateUM = prevState.copy( + content = when (val content = prevState.content) { + is NFTCollectionsUM.Content -> content.copy( + search = content.search.copy( + query = newQuery, + ), + ) + is NFTCollectionsUM.Empty, + is NFTCollectionsUM.Loading, + is NFTCollectionsUM.Failed, + -> content + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt new file mode 100644 index 0000000000..21618350db --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt @@ -0,0 +1,148 @@ +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.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.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@ModelScoped +internal class NFTCollectionsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val searchManager: InputManager, + private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase, + private val fetchNFTCollectionAssetsUseCase: FetchNFTCollectionAssetsUseCase, + paramsContainer: ParamsContainer, +) : Model() { + + val state: StateFlow get() = _state + + private val _state = MutableStateFlow( + value = NFTCollectionsStateUM( + onBackClick = ::navigateBack, + content = NFTCollectionsUM.Loading(::onReceiveClick), + ), + ) + + private val params: NFTCollectionsComponent.Params = paramsContainer.require() + + init { + subscribeToNFTCollections() + } + + private fun subscribeToNFTCollections() { + combine( + flow = getNFTCollectionsUseCase(params.userWalletId), + flow2 = searchManager.query.distinctUntilChanged(), + ) { nftCollections, query -> + _state.update { + UpdateDataStateTransformer( + nftCollections = nftCollections, + searchQuery = query, + onReceiveClick = ::onReceiveClick, + onRetryClick = ::onRetryClick, + onExpandCollectionClick = ::onExpandCollectionClick, + onRetryAssetsClick = ::onRetryAssetsClick, + onAssetClick = ::onAssetClick, + initialSearchBarFactory = ::getInitialSearchBar, + ).transform(it) + } + } + .launchIn(modelScope) + } + + private fun onSearchQueryChange(newQuery: String) { + modelScope.launch { + _state.update { UpdateSearchQueryTransformer(newQuery).transform(it) } + + searchManager.update(newQuery) + } + } + + private fun getInitialSearchBar(): SearchBarUM = SearchBarUM( + placeholderText = resourceReference(R.string.common_search), + query = "", + isActive = false, + onQueryChange = ::onSearchQueryChange, + onActiveChange = ::toggleSearchBar, + ) + + private fun toggleSearchBar(isActive: Boolean) { + _state.update { + ToggleSearchBarTransformer(isActive).transform(it) + } + } + + private fun onExpandCollectionClick(collection: NFTCollection) { + _state.update { + ChangeCollectionExpandedStateTransformer( + collectionId = collection.id, + onFirstExpanded = { onFirstExpanded(collection) }, + ).transform(it) + } + } + + private fun onFirstExpanded(collection: NFTCollection) { + loadCollectionAssets(collection) + } + + private fun onRetryAssetsClick(collection: NFTCollection) { + loadCollectionAssets(collection) + } + + private fun onRetryClick() { + // TODO refresh all + } + + 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( + userWalletId = params.userWalletId, + network = collection.network, + collectionId = collection.id, + ) + } + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt new file mode 100644 index 0000000000..ffaf0c94b1 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt @@ -0,0 +1,189 @@ +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 +import androidx.compose.material3.Text +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.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.impl.R +import kotlinx.collections.immutable.persistentListOf + +private const val CHEVRON_ROTATION_EXPANDED = 180f +private const val CHEVRON_ROTATION_COLLAPSED = 0f + +@Composable +internal fun NFTCollection(state: NFTCollectionUM, modifier: Modifier = Modifier) { + val isExpanded = state.isExpanded + + Column( + modifier = modifier, + ) { + Row( + modifier = Modifier + .clickable(onClick = state.onExpandClick) + .padding( + vertical = TangemTheme.dimens.spacing16, + horizontal = TangemTheme.dimens.spacing12, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Logo(state) + + Text(state) + + val rotation by animateFloatAsState( + targetValue = if (isExpanded) { + CHEVRON_ROTATION_EXPANDED + } else { + CHEVRON_ROTATION_COLLAPSED + }, + label = "chevron_rotation", + ) + + Icon( + modifier = Modifier + .rotate(rotation) + .size(TangemTheme.dimens.size24), + painter = painterResource(id = R.drawable.ic_chevron_24), + tint = if (isExpanded) { + TangemTheme.colors.icon.primary1 + } else { + TangemTheme.colors.icon.inactive + }, + contentDescription = null, + ) + } + } +} + +@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( + modifier = Modifier + .weight(1f) + .padding( + horizontal = TangemTheme.dimens.spacing12, + ), + ) { + Text( + modifier = Modifier, + text = state.name, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing2), + text = state.description.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTCollection(@PreviewParameter(NFTCollectionProvider::class) item: NFTCollectionUM) { + TangemThemePreview { + NFTCollection( + state = item, + ) + } +} + +private class NFTCollectionProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTCollectionUM( + id = "item1", + name = "Nethers", + logoUrl = "", + networkIconId = R.drawable.img_eth_22, + description = TextReference.Str("3 items"), + assets = NFTCollectionAssetsListUM.Content(persistentListOf()), + isExpanded = false, + onExpandClick = { }, + ), + NFTCollectionUM( + id = "item2", + name = "Nethers", + logoUrl = "", + networkIconId = R.drawable.img_eth_22, + description = TextReference.Str("3 items"), + assets = NFTCollectionAssetsListUM.Loading( + itemsCount = 3, + ), + onExpandClick = { }, + isExpanded = true, + ), + ), +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionAsset.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionAsset.kt new file mode 100644 index 0000000000..dfb100463f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionAsset.kt @@ -0,0 +1,123 @@ +package com.tangem.features.nft.collections.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.size +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.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.SpacerH12 +import com.tangem.core.ui.components.SpacerH2 +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.collections.entity.NFTCollectionAssetUM +import com.tangem.features.nft.collections.entity.NFTSalePriceUM +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTCollectionAsset(state: NFTCollectionAssetUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + ) { + SubcomposeAsyncImage( + modifier = Modifier + .aspectRatio(1f) + .clip(TangemTheme.shapes.roundedCornersXMedium), + model = ImageRequest.Builder(LocalContext.current) + .data(state.imageUrl) + .crossfade(true) + .build(), + loading = { + RectangleShimmer(radius = TangemTheme.dimens.radius16) + }, + error = { + Placeholder() + }, + contentScale = ContentScale.Crop, + contentDescription = null, + ) + SpacerH12() + Text( + text = state.name, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + SpacerH2() + NFTSalePrice( + state = state.price, + ) + } +} + +@Composable +private fun Placeholder(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.field.focused), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier.size(TangemTheme.dimens.size52), + painter = painterResource(R.drawable.ic_nft_placeholder_76), + contentDescription = null, + ) + } +} + +@Preview(widthDp = 180, showBackground = true) +@Preview(widthDp = 180, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTCollectionAsset( + @PreviewParameter(NFTCollectionAssetProvider::class) state: NFTCollectionAssetUM, +) { + TangemThemePreview { + NFTCollectionAsset( + state = state, + ) + } +} + +private class NFTCollectionAssetProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTCollectionAssetUM( + id = "item1", + name = "Nethers #0853", + imageUrl = "img", + price = NFTSalePriceUM.Loading, + onItemClick = { }, + ), + NFTCollectionAssetUM( + id = "item2", + name = "Nethers #0854", + imageUrl = "img", + price = NFTSalePriceUM.Failed, + onItemClick = { }, + ), + NFTCollectionAssetUM( + id = "item3", + name = "Nethers #0855", + imageUrl = "img", + price = NFTSalePriceUM.Content("0.05 ETH"), + onItemClick = { }, + ), + ), +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionAssetLoading.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionAssetLoading.kt new file mode 100644 index 0000000000..66bf216ad7 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionAssetLoading.kt @@ -0,0 +1,47 @@ +package com.tangem.features.nft.collections.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun NFTCollectionAssetLoading(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + ) { + RectangleShimmer( + modifier = Modifier + .aspectRatio(1f), + ) + SpacerH12() + TextShimmer( + modifier = Modifier + .width(TangemTheme.dimens.size86), + style = TangemTheme.typography.subtitle2, + textSizeHeight = true, + ) + SpacerH2() + TextShimmer( + modifier = Modifier + .width(TangemTheme.dimens.size48), + style = TangemTheme.typography.caption2, + textSizeHeight = true, + ) + } +} + +@Preview(widthDp = 180, showBackground = true) +@Preview(widthDp = 180, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTCollectionAsset() { + TangemThemePreview { + NFTCollectionAssetLoading() + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionWarning.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionWarning.kt new file mode 100644 index 0000000000..337e675026 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionWarning.kt @@ -0,0 +1,14 @@ +package com.tangem.features.nft.collections.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.features.nft.collections.entity.NFTCollectionsWarningUM + +@Composable +internal fun NFTCollectionWarning(state: NFTCollectionsWarningUM, modifier: Modifier = Modifier) { + Notification( + modifier = modifier, + config = state.config, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollections.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollections.kt new file mode 100644 index 0000000000..9dd7e06ffc --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollections.kt @@ -0,0 +1,49 @@ +package com.tangem.features.nft.collections.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.collections.entity.NFTCollectionsStateUM +import com.tangem.features.nft.collections.entity.NFTCollectionsUM +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTCollections(state: NFTCollectionsStateUM, modifier: Modifier = Modifier) { + BackHandler(onBack = state.onBackClick) + + Scaffold( + modifier = modifier, + containerColor = TangemTheme.colors.background.secondary, + topBar = { + AppBarWithBackButton( + modifier = Modifier.statusBarsPadding(), + onBackClick = state.onBackClick, + text = stringResourceSafe(id = R.string.nft_collections_title), + iconRes = R.drawable.ic_back_24, + ) + }, + content = { innerPadding -> + AnimatedContent( + targetState = state.content, + contentKey = { it::class }, + label = "NFT Collections", + ) { + val contentModifier = Modifier + .padding(innerPadding) + .fillMaxSize() + when (val content = it) { + is NFTCollectionsUM.Content -> NFTCollectionsContent(content, contentModifier) + is NFTCollectionsUM.Empty -> NFTCollectionsEmpty(content, contentModifier) + is NFTCollectionsUM.Failed -> NFTCollectionsFailed(content, contentModifier) + is NFTCollectionsUM.Loading -> Unit + } + } + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt new file mode 100644 index 0000000000..e1301e1146 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsContent.kt @@ -0,0 +1,349 @@ +package com.tangem.features.nft.collections.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.components.fields.SearchBar +import com.tangem.core.ui.components.fields.TangemSearchBarDefaults +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.collections.entity.* +import com.tangem.features.nft.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Suppress("LongMethod") +@Composable +internal fun NFTCollectionsContent(content: NFTCollectionsUM.Content, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary) + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + ) { + SearchBar( + state = content.search, + colors = TangemSearchBarDefaults.secondaryTextFieldColors, + ) + content.warnings.fastForEach { + key(it.id) { + NFTCollectionWarning( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16), + state = it, + ) + } + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding( + top = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing60, + ) + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary), + state = listState, + ) { + content.collections.fastForEach { collection -> + item(key = collection.id) { + NFTCollection( + modifier = Modifier.fillMaxWidth(), + state = collection, + ) + } + 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, + ) + } + } + } + } + } + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + text = stringResourceSafe(R.string.nft_collections_receive), + onClick = content.onReceiveClick, + ) + } +} + +private fun LazyListScope.assetsListLoading( + collectionId: String, + content: NFTCollectionAssetsListUM.Loading, + expanded: Boolean, +) { + val itemsCount = content.itemsCount + val rowCount = (itemsCount + 1) / 2 + repeat(rowCount) { rowIndex -> + item( + key = "loading_${collectionId}_$rowIndex", + ) { + AnimatedVisibility(visible = expanded) { + val paddingValues = PaddingValues( + start = TangemTheme.dimens.spacing6, + top = TangemTheme.dimens.spacing6, + end = TangemTheme.dimens.spacing6, + bottom = TangemTheme.dimens.spacing20, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing6) + .animateContentSize(), + ) { + NFTCollectionAssetLoading( + modifier = Modifier + .weight(1f) + .padding(paddingValues), + ) + if (rowIndex == rowCount - 1 && itemsCount % 2 != 0) { + Box( + modifier = Modifier + .weight(1f) + .padding(paddingValues), + ) + } else { + NFTCollectionAssetLoading( + modifier = Modifier + .weight(1f) + .padding(paddingValues), + ) + } + } + } + } + } +} + +private fun LazyListScope.assetsListFailed( + collectionId: String, + content: NFTCollectionAssetsListUM.Failed, + expanded: Boolean, +) { + item( + key = "failed_$collectionId", + ) { + AnimatedVisibility(visible = expanded) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(TangemTheme.dimens.size142) + .animateContentSize(), + contentAlignment = Alignment.Center, + ) { + UnableToLoadData( + onRetryClick = content.onRetryClick, + ) + } + } + } +} + +private fun LazyListScope.assetsListContent( + collectionId: String, + content: NFTCollectionAssetsListUM.Content, + expanded: Boolean, +) { + val items = content.items + val itemsCount = items.size + val rowCount = (itemsCount + 1) / 2 + repeat(rowCount) { rowIndex -> + val item1 = items[rowIndex * 2] + val item2 = items.getOrNull(rowIndex * 2 + 1) + item( + key = "content_${collectionId}_${item1.id}_${item2?.id}", + ) { + AnimatedVisibility(visible = expanded) { + val paddingValues = PaddingValues( + start = TangemTheme.dimens.spacing6, + top = TangemTheme.dimens.spacing6, + end = TangemTheme.dimens.spacing6, + bottom = TangemTheme.dimens.spacing20, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(TangemTheme.dimens.spacing6) + .animateContentSize(), + ) { + NFTCollectionAsset( + modifier = Modifier + .weight(1f) + .clip(TangemTheme.shapes.roundedCornersXMedium) + .clickable { item1.onItemClick() } + .padding(paddingValues), + state = item1, + ) + if (item2 == null) { + Box( + modifier = Modifier + .weight(1f) + .padding(paddingValues), + ) + } else { + NFTCollectionAsset( + modifier = Modifier + .weight(1f) + .clip(TangemTheme.shapes.roundedCornersXMedium) + .clickable { item2.onItemClick() } + .padding(paddingValues), + state = item2, + ) + } + } + } + } + } +} + +@Suppress("LongMethod") +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTCollectionsContent() { + TangemThemePreview { + NFTCollectionsContent( + content = NFTCollectionsUM.Content( + search = SearchBarUM( + placeholderText = resourceReference(R.string.common_search), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = { }, + ), + collections = persistentListOf( + NFTCollectionUM( + id = "item1", + name = "Nethers", + logoUrl = "", + networkIconId = R.drawable.img_eth_22, + description = TextReference.Str("3 items"), + assets = NFTCollectionAssetsListUM.Content(persistentListOf()), + isExpanded = false, + onExpandClick = { }, + ), + NFTCollectionUM( + id = "item2", + name = "Nethers", + logoUrl = "", + networkIconId = R.drawable.img_eth_22, + description = TextReference.Str("3 items"), + assets = NFTCollectionAssetsListUM.Loading( + itemsCount = 1, + ), + isExpanded = true, + onExpandClick = { }, + ), + NFTCollectionUM( + id = "item3", + name = "Nethers", + logoUrl = "", + networkIconId = R.drawable.img_eth_22, + description = TextReference.Str("3 items"), + assets = NFTCollectionAssetsListUM.Failed( + onRetryClick = { }, + ), + isExpanded = true, + onExpandClick = { }, + ), + NFTCollectionUM( + id = "item4", + name = "Nethers", + logoUrl = "", + networkIconId = R.drawable.img_eth_22, + description = TextReference.Str("3 items"), + assets = NFTCollectionAssetsListUM.Content( + items = persistentListOf( + NFTCollectionAssetUM( + id = "item1", + name = "Nethers #0854", + imageUrl = "img", + price = NFTSalePriceUM.Content("0.05 ETH"), + onItemClick = { }, + ), + NFTCollectionAssetUM( + id = "item2", + name = "Nethers #0855", + imageUrl = "img", + price = NFTSalePriceUM.Loading, + onItemClick = { }, + ), + NFTCollectionAssetUM( + id = "item3", + name = "Nethers #0856", + imageUrl = "img", + price = NFTSalePriceUM.Failed, + onItemClick = { }, + ), + ), + ), + isExpanded = true, + onExpandClick = { }, + ), + ), + warnings = persistentListOf( + NFTCollectionsWarningUM( + id = "loading troubles", + config = NotificationConfig( + title = TextReference.Res(R.string.nft_collections_warning_title), + subtitle = TextReference.Res(R.string.nft_collections_warning_subtitle), + iconResId = R.drawable.ic_alert_triangle_20, + ), + ), + ), + onReceiveClick = { }, + ), + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsEmpty.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsEmpty.kt new file mode 100644 index 0000000000..1376f56e72 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsEmpty.kt @@ -0,0 +1,74 @@ +package com.tangem.features.nft.collections.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.collections.entity.NFTCollectionsUM +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTCollectionsEmpty(state: NFTCollectionsUM.Empty, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .padding(TangemTheme.dimens.spacing16), + ) { + Column( + modifier = Modifier + .padding(TangemTheme.dimens.spacing16) + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_nft_placeholder_76), + contentDescription = null, + ) + Text( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing24), + text = stringResource(R.string.nft_collections_empty_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + text = stringResource(R.string.nft_collections_empty_description), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + PrimaryButton( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing48) + .wrapContentWidth(), + text = stringResourceSafe(R.string.nft_collections_receive), + onClick = state.onReceiveClick, + ) + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTCollectionsEmpty() { + TangemThemePreview { + NFTCollectionsEmpty( + state = NFTCollectionsUM.Empty( + onReceiveClick = { }, + ), + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt new file mode 100644 index 0000000000..d4d99bc548 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollectionsFailed.kt @@ -0,0 +1,54 @@ +package com.tangem.features.nft.collections.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.UnableToLoadData +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.collections.entity.NFTCollectionsUM +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTCollectionsFailed(state: NFTCollectionsUM.Failed, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .padding(TangemTheme.dimens.spacing16), + ) { + UnableToLoadData( + modifier = Modifier + .align(Alignment.Center), + onRetryClick = state.onRetryClick, + ) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + text = stringResourceSafe(R.string.nft_collections_receive), + onClick = state.onReceiveClick, + ) + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTCollectionsFailed() { + TangemThemePreview { + NFTCollectionsFailed( + state = NFTCollectionsUM.Failed( + onRetryClick = { }, + onReceiveClick = { }, + ), + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTSalePrice.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTSalePrice.kt new file mode 100644 index 0000000000..ce4a1db2cf --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTSalePrice.kt @@ -0,0 +1,40 @@ +package com.tangem.features.nft.collections.ui + +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.collections.entity.NFTSalePriceUM + +@Composable +internal fun NFTSalePrice(state: NFTSalePriceUM, modifier: Modifier = Modifier) { + when (state) { + is NFTSalePriceUM.Content -> { + Text( + modifier = modifier, + text = state.price, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + is NFTSalePriceUM.Failed -> { + Text( + modifier = modifier, + text = "", + style = TangemTheme.typography.caption2, + ) + } + is NFTSalePriceUM.Loading -> { + TextShimmer( + modifier = modifier + .width(TangemTheme.dimens.size48), + style = TangemTheme.typography.caption2, + ) + } + } +} \ 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 new file mode 100644 index 0000000000..bb220d9f9f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt @@ -0,0 +1,37 @@ +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/entity/NFTAssetUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt new file mode 100644 index 0000000000..2b85b58fe8 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt @@ -0,0 +1,59 @@ +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 kotlinx.collections.immutable.ImmutableList + +data class NFTAssetUM( + val name: String, + val media: Media, + val topInfo: TopInfo, + val traits: ImmutableList, + val baseInfoItems: ImmutableList, +) { + + @Immutable + sealed class TopInfo { + data object Empty : TopInfo() + data class Content( + val title: TextReference?, + val salePrice: SalePrice, + val description: String?, + val rarity: Rarity, + ) : TopInfo() + } + + @Immutable + sealed class Media { + data object Empty : Media() + data class Content( + val mimetype: String?, + val url: String, + ) : Media() + } + + @Immutable + sealed class SalePrice { + data object Loading : SalePrice() + data object Empty : SalePrice() + data class Content(val value: String, val fiatValue: String) : SalePrice() + } + + @Immutable + sealed class Rarity { + data object Empty : Rarity() + data class Content( + val rank: String, + val label: String, + val showDivider: Boolean, + ) : Rarity() + } + + data class BlockItem( + val title: TextReference, + val titleTextEllipsis: TextEllipsis = TextEllipsis.End, + val value: String, + val valueTextEllipsis: TextEllipsis = TextEllipsis.End, + ) +} \ 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 new file mode 100644 index 0000000000..55df42f2df --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTDetailsUM.kt @@ -0,0 +1,13 @@ +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 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/model/NFTDetailsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt new file mode 100644 index 0000000000..e421efcde8 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt @@ -0,0 +1,64 @@ +package com.tangem.features.nft.details.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.features.nft.component.NFTDetailsComponent +import com.tangem.features.nft.details.entity.NFTAssetUM +import com.tangem.features.nft.details.entity.NFTDetailsUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@ModelScoped +internal class NFTDetailsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + 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 fun onReadMoreClick() { + // TODO implement + } + + private fun onSeeAllClick() { + // TODO implement + } + + private fun onExploreClick() { + // TODO implement + } + + private fun onSendClick() { + // TODO implement + } + + private fun navigateBack() { + router.pop() + } +} \ 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 new file mode 100644 index 0000000000..31af37f744 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt @@ -0,0 +1,57 @@ +package com.tangem.features.nft.details.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.PrimaryButton +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.details.entity.NFTDetailsUM +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTDetails(state: NFTDetailsUM, 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 = state.nftAsset.name, + ) + }, + content = { innerPadding -> + NFTDetailsAsset( + state = state.nftAsset, + onReadMoreClick = state.onReadMoreClick, + onSeeAllClick = state.onSeeAllClick, + onExploreClick = state.onExploreClick, + modifier = Modifier + .padding(innerPadding), + ) + }, + floatingActionButtonPosition = FabPosition.Center, + floatingActionButton = { + PrimaryButton( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + text = stringResourceSafe(id = R.string.common_send), + onClick = state.onSendClick, + ) + }, + ) +} \ No newline at end of file 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 new file mode 100644 index 0000000000..b3806888f4 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsAsset.kt @@ -0,0 +1,178 @@ +package com.tangem.features.nft.details.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +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.atoms.text.TextEllipsis +import com.tangem.core.ui.components.bottomFade +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.details.entity.NFTAssetUM +import com.tangem.features.nft.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun NFTDetailsAsset( + state: NFTAssetUM, + onReadMoreClick: () -> Unit, + onSeeAllClick: () -> Unit, + onExploreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + val bottomBarHeight = with(LocalDensity.current) { + WindowInsets.systemBars.getBottom(density = this).toDp() + } + + val bottomPadding = TangemTheme.dimens.spacing76 + bottomBarHeight + + Box( + modifier = Modifier + .fillMaxSize() + .bottomFade(), + ) { + Column( + modifier = modifier + .verticalScroll(scrollState) + .padding( + start = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = bottomPadding, + ) + .fillMaxSize(), + ) { + NFTDetailsLogo( + state = state.media, + modifier = Modifier + .aspectRatio(1f), + ) + NFTDetailsInfoGroup( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12), + state = state.topInfo, + onReadMoreClick = onReadMoreClick, + ) + NFTDetailsBlocksGroup( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12), + items = state.traits, + title = resourceReference(R.string.nft_details_traits), + action = { + NFTBlocksGroupAction( + text = resourceReference(R.string.common_see_all), + startIcon = { }, + onClick = onSeeAllClick, + ) + }, + ) + NFTDetailsBlocksGroup( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing12), + items = state.baseInfoItems, + title = resourceReference(R.string.nft_details_base_information), + action = { + NFTBlocksGroupAction( + text = resourceReference(R.string.common_explore), + startIcon = { + NFTBlocksGroupActionIcon(iconRes = R.drawable.ic_compass_24) + }, + onClick = onExploreClick, + ) + }, + ) + } + } +} + +@Preview(widthDp = 360, heightDp = 1000, showBackground = true) +@Preview(widthDp = 360, heightDp = 1000, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTDetailsAssetAsset(@PreviewParameter(NFTAssetProvider::class) state: NFTAssetUM) { + TangemThemePreview { + NFTDetailsAsset( + state = state, + onReadMoreClick = { }, + onSeeAllClick = { }, + onExploreClick = { }, + ) + } +} + +@Suppress("MagicNumber") +private class NFTAssetProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTAssetUM( + name = "Nethers #9744", + media = NFTAssetUM.Media.Content( + url = "img1", + mimetype = "image/jpeg", + ), + topInfo = NFTAssetUM.TopInfo.Content( + title = resourceReference(R.string.nft_details_last_sale_price), + salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"), + description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3), + rarity = NFTAssetUM.Rarity.Content( + rank = "Top 1% rarity", + label = "115.28", + showDivider = true, + ), + ), + traits = persistentListOf( + NFTAssetUM.BlockItem( + title = stringReference("Tier"), + value = "Infinite", + ), + NFTAssetUM.BlockItem( + title = stringReference("Phygital Toy"), + value = "None", + ), + NFTAssetUM.BlockItem( + title = stringReference("Class"), + value = "CYBER", + ), + NFTAssetUM.BlockItem( + title = stringReference("Accessory"), + value = "No accessory", + ), + NFTAssetUM.BlockItem( + title = stringReference("Sneakers"), + value = "Boots", + ), + NFTAssetUM.BlockItem( + title = stringReference("Artist"), + value = "DJ Dragoon", + ), + ), + baseInfoItems = persistentListOf( + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_standard), + value = "ERC-721", + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_contract_address), + value = "0x6811f2fgd892ac83f719", + valueTextEllipsis = TextEllipsis.Middle, + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_token_id), + value = "100200273", + ), + NFTAssetUM.BlockItem( + title = resourceReference(R.string.nft_details_chain), + value = "Ethereum", + ), + ), + ), + ), +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsBlocksGroup.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsBlocksGroup.kt new file mode 100644 index 0000000000..8df906ff96 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsBlocksGroup.kt @@ -0,0 +1,246 @@ +package com.tangem.features.nft.details.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.components.block.information.InformationBlock +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.details.entity.NFTAssetUM +import com.tangem.features.nft.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun NFTDetailsBlocksGroup( + title: TextReference, + action: (@Composable BoxScope.() -> Unit)?, + items: ImmutableList, + modifier: Modifier = Modifier, +) { + val itemsCount = items.size + if (items.size > 0) { + val rowCount = (itemsCount + 1) / 2 + val paddingValues = PaddingValues( + horizontal = TangemTheme.dimens.spacing6, + vertical = TangemTheme.dimens.spacing8, + ) + InformationBlock( + modifier = modifier, + title = { + NFTDetailsGroupTitle( + text = title, + ) + }, + action = action, + contentHorizontalPadding = TangemTheme.dimens.spacing6, + ) { + Column( + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing12), + ) { + repeat(rowCount) { rowIndex -> + val item1 = items[rowIndex * 2] + val item2 = items.getOrNull(rowIndex * 2 + 1) + key(rowIndex) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + NFTDetailsGroupBlock( + modifier = Modifier + .weight(1f) + .padding(paddingValues), + title = item1.title, + value = stringReference(item1.value), + titleEllipsis = item1.titleTextEllipsis, + valueEllipsis = item1.valueTextEllipsis, + ) + if (item2 == null) { + Box( + modifier = Modifier + .weight(1f) + .padding(paddingValues), + ) + } else { + NFTDetailsGroupBlock( + modifier = Modifier + .weight(1f) + .padding(paddingValues), + title = item2.title, + value = stringReference(item2.value), + titleEllipsis = item2.titleTextEllipsis, + valueEllipsis = item2.valueTextEllipsis, + ) + } + } + } + } + } + } + } +} + +@Composable +internal fun NFTDetailsGroupTitle(text: TextReference, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = text.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Composable +internal fun NFTDetailsGroupBlock( + title: TextReference, + value: TextReference, + modifier: Modifier = Modifier, + titleEllipsis: TextEllipsis = TextEllipsis.End, + valueEllipsis: TextEllipsis = TextEllipsis.End, +) { + Column( + modifier = modifier, + ) { + EllipsisText( + text = title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = titleEllipsis, + ) + EllipsisText( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing4), + text = value.resolveReference(), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ellipsis = valueEllipsis, + ) + } +} + +@Composable +internal fun NFTBlocksGroupAction( + text: TextReference, + startIcon: @Composable RowScope.() -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + + Row( + modifier = modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing2), + ) { + startIcon() + Text( + text = text.resolveReference(), + color = TangemTheme.colors.text.accent, + style = TangemTheme.typography.subtitle2, + ) + } +} + +@Composable +internal fun NFTBlocksGroupActionIcon(@DrawableRes iconRes: Int, modifier: Modifier = Modifier) { + Icon( + modifier = modifier.size(size = TangemTheme.dimens.size16), + painter = painterResource(id = iconRes), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) +} + +@Preview(widthDp = 360) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTDetailsInfoBlock( + @PreviewParameter(NFTAssetBlocksProvider::class) state: ImmutableList, +) { + TangemThemePreview { + Column { + NFTDetailsBlocksGroup( + title = stringReference("Title"), + action = null, + items = state, + ) + NFTDetailsBlocksGroup( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + title = stringReference("Title"), + action = { + NFTBlocksGroupAction( + text = resourceReference(R.string.common_see_all), + startIcon = { }, + onClick = { }, + ) + }, + items = state, + ) + NFTDetailsBlocksGroup( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + title = stringReference("Title"), + action = { + NFTBlocksGroupAction( + text = resourceReference(R.string.common_explore), + startIcon = { + NFTBlocksGroupActionIcon(iconRes = R.drawable.ic_compass_24) + }, + onClick = { }, + ) + }, + items = state, + ) + } + } +} + +private class NFTAssetBlocksProvider : CollectionPreviewParameterProvider>( + collection = listOf( + persistentListOf( + NFTAssetUM.BlockItem( + title = stringReference("Tier"), + value = "Infinite", + ), + NFTAssetUM.BlockItem( + title = stringReference("Phygital Toy"), + value = "None", + ), + NFTAssetUM.BlockItem( + title = stringReference("Class"), + value = "CYBER", + ), + NFTAssetUM.BlockItem( + title = stringReference("Accessory"), + value = "No accessory", + ), + NFTAssetUM.BlockItem( + title = stringReference("Sneakers"), + value = "Boots", + ), + ), + ), +) \ No newline at end of file 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 new file mode 100644 index 0000000000..aa34278977 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsInfoGroup.kt @@ -0,0 +1,266 @@ +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.layout.* +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.TextShimmer +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.block.information.InformationBlock +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.details.entity.NFTAssetUM +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTDetailsInfoGroup( + state: NFTAssetUM.TopInfo, + onReadMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is NFTAssetUM.TopInfo.Empty -> Unit + is NFTAssetUM.TopInfo.Content -> { + InformationBlock( + modifier = modifier, + title = if (state.title != null) { + { NFTDetailsGroupTitle(state.title) } + } else { + null + }, + contentHorizontalPadding = 0.dp, + ) { + Column( + modifier = Modifier, + ) { + SalePrice(state.salePrice) + Description(state.description, onReadMoreClick) + Rarity(state.rarity) + } + } + } + } +} + +@Composable +private fun SalePrice(state: NFTAssetUM.SalePrice, modifier: Modifier = Modifier) { + AnimatedVisibility( + modifier = Modifier.fillMaxWidth().animateContentSize(), + visible = state !is NFTAssetUM.SalePrice.Empty, + enter = fadeIn(), + exit = fadeOut(), + ) { + when (state) { + is NFTAssetUM.SalePrice.Empty -> Unit + is NFTAssetUM.SalePrice.Loading -> { + Column( + modifier = modifier + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + ), + ) { + TextShimmer( + modifier = Modifier + .width(TangemTheme.dimens.size158), + style = TangemTheme.typography.head, + textSizeHeight = true, + ) + TextShimmer( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing4) + .width(TangemTheme.dimens.size90), + style = TangemTheme.typography.caption2, + textSizeHeight = true, + ) + } + } + is NFTAssetUM.SalePrice.Content -> { + Column( + modifier = modifier + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + ), + verticalArrangement = Arrangement.SpaceAround, + ) { + Text( + text = state.value, + style = TangemTheme.typography.head, + color = TangemTheme.colors.text.primary1, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing4), + text = state.fiatValue, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + } +} + +@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( + modifier = modifier + .clickable { onReadMoreClick() } + .padding( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing8, + ), + text = description, + style = TangemTheme.typography.body2.copy( + color = TangemTheme.colors.text.tertiary, + ), + ) + } +} + +@Composable +private fun Rarity(state: NFTAssetUM.Rarity, modifier: Modifier = Modifier) { + when (state) { + is NFTAssetUM.Rarity.Empty -> Unit + is NFTAssetUM.Rarity.Content -> { + if (state.showDivider) { + HorizontalDivider( + modifier = Modifier.padding( + horizontal = TangemTheme.dimens.spacing12, + ), + color = TangemTheme.colors.stroke.primary, + ) + } + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = TangemTheme.dimens.spacing6, + vertical = TangemTheme.dimens.spacing8, + ), + ) { + NFTDetailsGroupBlock( + 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), + ) + NFTDetailsGroupBlock( + 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), + ) + } + } + } +} + +@Preview(widthDp = 360) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTDetailsInfoBlock(@PreviewParameter(NFTAssetInfoProvider::class) state: NFTAssetUM.TopInfo) { + TangemThemePreview { + NFTDetailsInfoGroup( + state = state, + onReadMoreClick = { }, + ) + } +} + +@Suppress("LongMethod", "MagicNumber") +private class NFTAssetInfoProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTAssetUM.TopInfo.Content( + title = resourceReference(R.string.nft_details_last_sale_price), + salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"), + description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3), + rarity = NFTAssetUM.Rarity.Content( + rank = "Top 1% rarity", + label = "115.28", + showDivider = true, + ), + ), + NFTAssetUM.TopInfo.Content( + title = resourceReference(R.string.nft_details_last_sale_price), + salePrice = NFTAssetUM.SalePrice.Loading, + description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3), + rarity = NFTAssetUM.Rarity.Content( + rank = "Top 1% rarity", + label = "115.28", + showDivider = true, + ), + ), + NFTAssetUM.TopInfo.Content( + title = resourceReference(R.string.nft_details_last_sale_price), + salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"), + description = null, + rarity = NFTAssetUM.Rarity.Content( + rank = "Top 1% rarity", + label = "115.28", + showDivider = true, + ), + ), + NFTAssetUM.TopInfo.Content( + title = resourceReference(R.string.nft_details_last_sale_price), + salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"), + description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3), + rarity = NFTAssetUM.Rarity.Empty, + ), + NFTAssetUM.TopInfo.Content( + title = resourceReference(R.string.nft_details_last_sale_price), + salePrice = NFTAssetUM.SalePrice.Content(value = "0,012 ETH", fiatValue = "32.34$"), + description = null, + rarity = NFTAssetUM.Rarity.Empty, + ), + NFTAssetUM.TopInfo.Content( + title = null, + salePrice = NFTAssetUM.SalePrice.Empty, + description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3), + rarity = NFTAssetUM.Rarity.Content( + rank = "Top 1% rarity", + label = "115.28", + showDivider = true, + ), + ), + NFTAssetUM.TopInfo.Content( + title = null, + salePrice = NFTAssetUM.SalePrice.Empty, + description = null, + rarity = NFTAssetUM.Rarity.Content( + rank = "Top 1% rarity", + label = "115.28", + showDivider = false, + ), + ), + NFTAssetUM.TopInfo.Content( + title = null, + salePrice = NFTAssetUM.SalePrice.Empty, + description = "Base edition by Piux. An illustration of Crypto Robot #7804".repeat(3), + rarity = NFTAssetUM.Rarity.Empty, + ), + ), +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsLogo.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsLogo.kt new file mode 100644 index 0000000000..3111614efd --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetailsLogo.kt @@ -0,0 +1,95 @@ +package com.tangem.features.nft.details.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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 coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.details.entity.NFTAssetUM +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTDetailsLogo(state: NFTAssetUM.Media, modifier: Modifier = Modifier) { + when (state) { + is NFTAssetUM.Media.Empty -> { + Placeholder( + modifier = modifier.fillMaxWidth(), + ) + } + is NFTAssetUM.Media.Content -> { + SubcomposeAsyncImage( + modifier = modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium), + model = ImageRequest.Builder(LocalContext.current) + .data(state.url) + .crossfade(true) + .build(), + loading = { + RectangleShimmer(radius = TangemTheme.dimens.radius16) + }, + error = { + Placeholder() + }, + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } + } +} + +@Composable +private fun Placeholder(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.field.focused), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier.size(TangemTheme.dimens.size120), + painter = painterResource(R.drawable.ic_nft_placeholder_76), + contentDescription = null, + ) + } +} + +@Preview(widthDp = 360) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTDetailsInfoBlock(@PreviewParameter(NFTAssetMediaProvider::class) state: NFTAssetUM.Media) { + TangemThemePreview { + NFTDetailsLogo( + state = state, + modifier = Modifier + .aspectRatio(1f), + ) + } +} + +private class NFTAssetMediaProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTAssetUM.Media.Empty, + NFTAssetUM.Media.Content( + mimetype = "image/jpeg", + url = "img1", + ), + ), +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/DefaultNFTReceiveComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/DefaultNFTReceiveComponent.kt new file mode 100644 index 0000000000..f9651f18e3 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/DefaultNFTReceiveComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.nft.receive + +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.NFTReceiveComponent +import com.tangem.features.nft.receive.model.NFTReceiveModel +import com.tangem.features.nft.receive.ui.NFTReceive +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNFTReceiveComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NFTReceiveComponent.Params, +) : NFTReceiveComponent, AppComponentContext by context { + + private val model: NFTReceiveModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + NFTReceive(state, modifier) + } + + @AssistedFactory + interface Factory : NFTReceiveComponent.Factory { + override fun create( + context: AppComponentContext, + params: NFTReceiveComponent.Params, + ): DefaultNFTReceiveComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTNetworkUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTNetworkUM.kt new file mode 100644 index 0000000000..0ef73aa689 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTNetworkUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.nft.receive.entity + +import androidx.annotation.DrawableRes + +internal data class NFTNetworkUM( + val id: String, + @DrawableRes val iconRes: Int, + val name: String, + val onItemClick: () -> Unit, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTReceiveUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTReceiveUM.kt new file mode 100644 index 0000000000..0764b2f074 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/NFTReceiveUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.nft.receive.entity + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.fields.entity.SearchBarUM +import kotlinx.collections.immutable.ImmutableList + +internal data class NFTReceiveUM( + val onBackClick: () -> Unit, + val search: SearchBarUM, + val networks: Networks, + val bottomSheetConfig: TangemBottomSheetConfig?, +) { + + @Immutable + sealed class Networks { + data object Empty : Networks() + + data class Content( + val items: ImmutableList, + ) : Networks() + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/ShowReceiveBottomSheetTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/ShowReceiveBottomSheetTransformer.kt new file mode 100644 index 0000000000..4fc0f0de2d --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/ShowReceiveBottomSheetTransformer.kt @@ -0,0 +1,32 @@ +package com.tangem.features.nft.receive.entity.transformer + +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkAddress +import com.tangem.features.nft.receive.entity.NFTReceiveUM +import com.tangem.utils.transformer.Transformer + +internal class ShowReceiveBottomSheetTransformer( + private val network: Network, + private val networkAddress: NetworkAddress, + private val onDismissBottomSheet: () -> Unit, + private val onCopyClick: (String) -> Unit, + private val onShareClick: (String) -> Unit, +) : Transformer { + + override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismissBottomSheet, + content = TokenReceiveBottomSheetConfig( + asset = TokenReceiveBottomSheetConfig.Asset.NFT, + network = network, + networkAddress = networkAddress, + showMemoDisclaimer = network.transactionExtrasType != Network.TransactionExtrasType.NONE, + onCopyClick = onCopyClick, + onShareClick = onShareClick, + ), + ), + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/ToggleSearchBarTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/ToggleSearchBarTransformer.kt new file mode 100644 index 0000000000..16b51e66a9 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/ToggleSearchBarTransformer.kt @@ -0,0 +1,13 @@ +package com.tangem.features.nft.receive.entity.transformer + +import com.tangem.features.nft.receive.entity.NFTReceiveUM +import com.tangem.utils.transformer.Transformer + +internal class ToggleSearchBarTransformer(private val isActive: Boolean) : Transformer { + + override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy( + search = prevState.search.copy( + isActive = isActive, + ), + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt new file mode 100644 index 0000000000..560bf018f7 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt @@ -0,0 +1,34 @@ +package com.tangem.features.nft.receive.entity.transformer + +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.domain.tokens.model.Network +import com.tangem.features.nft.receive.entity.NFTNetworkUM +import com.tangem.features.nft.receive.entity.NFTReceiveUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class UpdateDataStateTransformer( + private val networks: List, + private val onNetworkClick: (Network) -> Unit, +) : Transformer { + + override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy( + networks = when { + networks.isEmpty() -> NFTReceiveUM.Networks.Empty + else -> NFTReceiveUM.Networks.Content( + items = networks + .map { it.transform() } + .toPersistentList(), + ) + }, + ) + + private fun Network.transform(): NFTNetworkUM = NFTNetworkUM( + id = id.value, + iconRes = getActiveIconRes(id.value), + name = name, + onItemClick = { + onNetworkClick(this) + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateSearchQueryTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateSearchQueryTransformer.kt new file mode 100644 index 0000000000..6e2d305b3d --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateSearchQueryTransformer.kt @@ -0,0 +1,13 @@ +package com.tangem.features.nft.receive.entity.transformer + +import com.tangem.features.nft.receive.entity.NFTReceiveUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateSearchQueryTransformer(private val newQuery: String) : Transformer { + + override fun transform(prevState: NFTReceiveUM): NFTReceiveUM = prevState.copy( + search = prevState.search.copy( + query = newQuery, + ), + ) +} \ 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 new file mode 100644 index 0000000000..dc0eefd9b3 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/model/NFTReceiveModel.kt @@ -0,0 +1,147 @@ +package com.tangem.features.nft.receive.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.ui.clipboard.ClipboardManager +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.FilterNFTAvailableNetworksUseCase +import com.tangem.domain.nft.GetNFTAvailableNetworksUseCase +import com.tangem.domain.nft.GetNFTNetworkStatusUseCase +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.entity.NFTReceiveUM +import com.tangem.features.nft.receive.entity.transformer.ShowReceiveBottomSheetTransformer +import com.tangem.features.nft.receive.entity.transformer.ToggleSearchBarTransformer +import com.tangem.features.nft.receive.entity.transformer.UpdateDataStateTransformer +import com.tangem.features.nft.receive.entity.transformer.UpdateSearchQueryTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@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, + paramsContainer: ParamsContainer, +) : Model() { + + val state: StateFlow get() = _state + + private val _state = MutableStateFlow( + value = NFTReceiveUM( + onBackClick = ::navigateBack, + search = getInitialSearchBar(), + networks = NFTReceiveUM.Networks.Content(persistentListOf()), + bottomSheetConfig = null, + ), + ) + + private val params: NFTReceiveComponent.Params = paramsContainer.require() + + init { + subscribeToNFTAvailableNetworks() + } + + private fun subscribeToNFTAvailableNetworks() { + combine( + flow = getNFTAvailableNetworksUseCase(params.userWalletId), + flow2 = searchManager.query.distinctUntilChanged(), + ) { networks, query -> + filterNFTAvailableNetworksUseCase(networks, query) + } + .onEach { filteredNetworks -> + _state.update { + UpdateDataStateTransformer( + networks = filteredNetworks, + onNetworkClick = ::onNetworkClick, + ).transform(it) + } + } + .launchIn(modelScope) + } + + private fun onSearchQueryChange(newQuery: String) { + modelScope.launch { + _state.update { UpdateSearchQueryTransformer(newQuery).transform(it) } + + searchManager.update(newQuery) + } + } + + private fun getInitialSearchBar(): SearchBarUM = SearchBarUM( + placeholderText = resourceReference(R.string.common_search), + query = "", + isActive = false, + onQueryChange = ::onSearchQueryChange, + onActiveChange = ::toggleSearchBar, + ) + + private fun toggleSearchBar(isActive: Boolean) { + _state.update { + ToggleSearchBarTransformer(isActive).transform(it) + } + } + + private fun onReceiveBottomSheetDismiss() { + _state.update { + it.copy( + bottomSheetConfig = it.bottomSheetConfig?.copy(isShown = false), + ) + } + } + + private fun onNetworkClick(network: Network) { + modelScope.launch { + val networkStatus = getNFTNetworkStatusUseCase.invoke( + userWalletId = params.userWalletId, + network = network, + ) ?: return@launch + + when (val value = networkStatus.value) { + is NetworkStatus.Verified -> { + _state.update { + ShowReceiveBottomSheetTransformer( + network = network, + networkAddress = value.address, + onDismissBottomSheet = ::onReceiveBottomSheetDismiss, + onCopyClick = ::onCopyClick, + onShareClick = ::onShareClick, + ).transform(it) + } + } + is NetworkStatus.MissedDerivation, + is NetworkStatus.NoAccount, + is NetworkStatus.Unreachable, + -> Unit + } + } + } + + private fun onCopyClick(text: String) { + clipboardManager.setText(text = text, isSensitive = true) + } + + private fun onShareClick(text: String) { + 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/NFTNetwork.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTNetwork.kt new file mode 100644 index 0000000000..9b60320b49 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTNetwork.kt @@ -0,0 +1,73 @@ +package com.tangem.features.nft.receive.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.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 com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.impl.R +import com.tangem.features.nft.receive.entity.NFTNetworkUM + +@Composable +internal fun NFTNetwork(state: NFTNetworkUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { state.onItemClick() } + .padding(TangemTheme.dimens.spacing12), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier + .size(TangemTheme.dimens.size24) + .clip(CircleShape), + painter = painterResource(state.iconRes), + contentDescription = null, + ) + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing12), + text = state.name, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTNetwork(@PreviewParameter(NFTNetworkProvider::class) state: NFTNetworkUM) { + TangemThemePreview { + NFTNetwork( + state = state, + ) + } +} + +private class NFTNetworkProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTNetworkUM( + id = "item1", + name = "Nethers #0853", + iconRes = R.drawable.img_eth_22, + onItemClick = { }, + ), + ), +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceive.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceive.kt new file mode 100644 index 0000000000..69acaeef22 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceive.kt @@ -0,0 +1,82 @@ +package com.tangem.features.nft.receive.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.fields.SearchBar +import com.tangem.core.ui.components.fields.TangemSearchBarDefaults +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.receive.entity.NFTReceiveUM + +@Composable +internal fun NFTReceive(state: NFTReceiveUM, 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_close_24, + onIconClicked = state.onBackClick, + ), + title = stringResourceSafe(id = R.string.nft_receive_title), + subtitle = stringResourceSafe(id = R.string.nft_receive_subtitle), + ) + }, + content = { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + SearchBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + state = state.search, + colors = TangemSearchBarDefaults.secondaryTextFieldColors, + ) + + val contentModifier = Modifier + .padding(TangemTheme.dimens.spacing16) + + AnimatedContent( + targetState = state.networks, + contentKey = { it::class }, + label = "NFT Receive", + ) { + when (val networks = it) { + is NFTReceiveUM.Networks.Content -> NFTReceiveNetworksContent(networks, contentModifier) + is NFTReceiveUM.Networks.Empty -> NFTReceiveNetworksEmpty(contentModifier) + } + } + } + }, + ) + + ShowBottomSheet(state.bottomSheetConfig) +} + +@Composable +private fun ShowBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) { + if (bottomSheetConfig != null) { + when (bottomSheetConfig.content) { + is TokenReceiveBottomSheetConfig -> { + TokenReceiveBottomSheet(config = bottomSheetConfig) + } + } + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksContent.kt new file mode 100644 index 0000000000..23fab1ef15 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksContent.kt @@ -0,0 +1,106 @@ +package com.tangem.features.nft.receive.ui + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +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.impl.R +import com.tangem.features.nft.receive.entity.NFTNetworkUM +import com.tangem.features.nft.receive.entity.NFTReceiveUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun NFTReceiveNetworksContent(state: NFTReceiveUM.Networks.Content, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + + LazyColumn( + modifier = modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary) + .animateContentSize(), + state = listState, + ) { + item( + key = "title", + ) { + Title() + } + items( + items = state.items, + key = NFTNetworkUM::id, + ) { network -> + NFTNetwork( + state = network, + modifier = Modifier.animateItem(), + ) + } + } +} + +@Composable +private fun Title(modifier: Modifier = Modifier) { + Text( + modifier = modifier + .padding( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing4, + ), + text = stringResourceSafe(R.string.nft_receive_choose_network), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) +} + +@Suppress("LongMethod") +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTReceiveNetworksUM() { + TangemThemePreview { + NFTReceiveNetworksContent( + state = NFTReceiveUM.Networks.Content( + items = persistentListOf( + NFTNetworkUM( + id = "item1", + name = "Ethereum", + iconRes = R.drawable.img_eth_22, + onItemClick = { }, + ), + NFTNetworkUM( + id = "item2", + name = "Polygon", + iconRes = R.drawable.img_polygon_22, + onItemClick = { }, + ), + NFTNetworkUM( + id = "item3", + name = "BSC", + iconRes = R.drawable.img_bsc_22, + onItemClick = { }, + ), + NFTNetworkUM( + id = "item4", + name = "Avalanche", + iconRes = R.drawable.img_avalanche_22, + onItemClick = { }, + ), + ), + ), + ) + } +} \ 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 new file mode 100644 index 0000000000..cebaa5a91d --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/ui/NFTReceiveNetworksEmpty.kt @@ -0,0 +1,18 @@ +package com.tangem.features.nft.receive.ui + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +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, + ) +} \ No newline at end of file diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt index 1845005df6..dbeb952224 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt @@ -4,4 +4,5 @@ interface OnboardingV2FeatureToggles { val isOnboardingV2Enabled: Boolean val isVisaOnboardingEnabled: Boolean val isNoteRefactoringEnabled: Boolean + val isTwinRefactoringEnabled: Boolean } \ No newline at end of file diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt index 3728ca80ae..b52fabf421 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt @@ -8,12 +8,14 @@ interface OnboardingEntryComponent : ComposableContentComponent { data class Params( val scanResponse: ScanResponse, - val multiWalletMode: MultiWalletMode, + val mode: Mode, ) - enum class MultiWalletMode { + enum class Mode { Onboarding, - AddBackup, + AddBackupWallet1, + WelcomeOnlyTwin, + RecreateWalletTwin, ContinueFinalize, } diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index a848d97068..c557f4a7a8 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.navigation) implementation(projects.core.datasource) + implementation(projects.core.error.ext) /** Common */ implementation(projects.common.ui) @@ -43,6 +44,9 @@ dependencies { implementation(projects.domain.settings) implementation(projects.domain.onboarding) implementation(projects.domain.visa) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.onramp) /** Tangem libraries */ implementation(projects.libs.tangemSdkApi) @@ -63,6 +67,7 @@ dependencies { implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) implementation(deps.compose.coil) + implementation(deps.compose.shimmer) implementation(deps.lottie.compose) implementation(deps.decompose.ext.compose) implementation(deps.androidx.activity.compose) @@ -73,6 +78,7 @@ dependencies { implementation(deps.kotlin.serialization) implementation(deps.timber) implementation(deps.firebase.crashlytics) + implementation(tangemDeps.card.core) /** DI */ implementation(deps.hilt.android) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt index 41b7796f7a..80bbb18b70 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt @@ -12,4 +12,6 @@ internal class DefaultOnboardingV2FeatureToggles @Inject constructor( get() = featureTogglesManager.isFeatureEnabled("VISA_ONBOARDING_ENABLED") override val isNoteRefactoringEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled("NOTE_REFACTORING_ENABLED") + override val isTwinRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled("TWIN_REFACTORING_ENABLED") } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/analytics/OnboardingEvent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/analytics/OnboardingEvent.kt similarity index 69% rename from features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/analytics/OnboardingEvent.kt rename to features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/analytics/OnboardingEvent.kt index f6ffdafe88..053c5f9930 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/analytics/OnboardingEvent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/analytics/OnboardingEvent.kt @@ -1,6 +1,8 @@ -package com.tangem.features.onboarding.v2.multiwallet.impl.analytics +package com.tangem.features.onboarding.v2.common.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.tokens.model.CryptoCurrency sealed class OnboardingEvent( category: String, @@ -38,6 +40,21 @@ sealed class OnboardingEvent( } } + sealed class Topup( + event: String, + params: Map = mapOf(), + ) : OnboardingEvent("Onboarding / Top Up", event, params) { + + object ScreenOpened : Topup("Activation Screen Opened") + + object ButtonShowWalletAddress : Topup("Button - Show the Wallet Address") + + class ButtonBuyCrypto(currency: CryptoCurrency) : Topup( + event = "Button - Buy Crypto", + params = mapOf(AnalyticsParam.CURRENCY to currency.symbol), + ) + } + sealed class Backup( event: String, params: Map = mapOf(), @@ -75,4 +92,22 @@ sealed class OnboardingEvent( params = mapOf("Action" to "Cancel"), ) } + + sealed class Twins( + event: String, + params: Map = mapOf(), + ) : OnboardingEvent("Onboarding / Twins", event, params) { + + data object ScreenOpened : Twins("Twinning Screen Opened") + data object SetupStarted : Twins("Twin Setup Started") + data object SetupFinished : Twins("Twin Setup Finished") + } + + data class OfflineAttestationFailed( + val source: AnalyticsParam.ScreensSources, + ) : OnboardingEvent( + category = "Error", + event = "Offline Attestation Failed", + params = mapOf(AnalyticsParam.SOURCE to source.value), + ) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/Dialogs.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/Dialogs.kt new file mode 100644 index 0000000000..7fb5ae3b9b --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/Dialogs.kt @@ -0,0 +1,39 @@ +package com.tangem.features.onboarding.v2.common.ui + +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.features.onboarding.v2.impl.R + +internal fun exitOnboardingDialog(onConfirm: () -> Unit): DialogMessage { + return DialogMessage( + title = resourceReference(R.string.onboarding_exit_alert_title), + message = resourceReference(R.string.onboarding_exit_alert_message), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_yes), + onClick = onConfirm, + ) + }, + secondActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_cancel), + onClick = {}, + ) + }, + ) +} + +internal fun interruptBackupDialog(onConfirm: () -> Unit) = DialogMessage( + title = resourceReference(R.string.onboarding_exit_alert_title), + message = resourceReference(R.string.onboarding_exit_alert_message), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_ok), + warning = true, + onClick = onConfirm, + ), + secondAction = EventMessageAction( + title = resourceReference(R.string.common_cancel), + onClick = {}, + ), +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/common/ui/OnboardingDialogUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/OnboardingDialogUM.kt similarity index 87% rename from features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/common/ui/OnboardingDialogUM.kt rename to features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/OnboardingDialogUM.kt index d5050f50e6..53e6ba8d82 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/common/ui/OnboardingDialogUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/OnboardingDialogUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.onboarding.v2.multiwallet.impl.common.ui +package com.tangem.features.onboarding.v2.common.ui import com.tangem.common.ui.alerts.models.AlertUM import com.tangem.core.ui.extensions.TextReference diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/RefreshButton.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/RefreshButton.kt new file mode 100644 index 0000000000..12cde207b1 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/RefreshButton.kt @@ -0,0 +1,51 @@ +package com.tangem.features.onboarding.v2.common.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.onboarding.v2.impl.R + +@Composable +internal fun RefreshButton(isRefreshing: Boolean, onRefreshBalanceClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(TangemTheme.dimens.size48) + .shadow(elevation = 2.dp, shape = CircleShape) + .background(TangemTheme.colors.background.action) + .clickable( + enabled = !isRefreshing, + onClick = onRefreshBalanceClick, + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + ), + ) { + if (isRefreshing) { + CircularProgressIndicator( + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + color = TangemTheme.colors.icon.primary1, + strokeWidth = TangemTheme.dimens.size2, + ) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_refresh_24), + contentDescription = null, + tint = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(TangemTheme.dimens.spacing12), + ) + } + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/api/OnboardingDoneComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/api/OnboardingDoneComponent.kt index 079b169a7e..2863a3876a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/api/OnboardingDoneComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/api/OnboardingDoneComponent.kt @@ -5,7 +5,14 @@ import com.tangem.core.ui.decompose.ComposableContentComponent interface OnboardingDoneComponent : ComposableContentComponent { - data class Params(val onDone: () -> Unit) + data class Params( + val mode: Mode, + val onDone: () -> Unit, + ) + + enum class Mode { + WalletCreated, GoodToGo + } interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/impl/DefaultOnboardingDoneComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/impl/DefaultOnboardingDoneComponent.kt index f36c949bfe..a32bb8cc28 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/impl/DefaultOnboardingDoneComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/impl/DefaultOnboardingDoneComponent.kt @@ -1,7 +1,6 @@ package com.tangem.features.onboarding.v2.done.impl 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.features.onboarding.v2.done.api.OnboardingDoneComponent @@ -18,7 +17,8 @@ internal class DefaultOnboardingDoneComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { OnboardingDone( - onContinueClick = remember(this) { params.onDone }, + mode = params.mode, + onContinueClick = params.onDone, ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/impl/ui/OnboardingDone.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/impl/ui/OnboardingDone.kt index f3c8aa0111..309b940ac9 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/impl/ui/OnboardingDone.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/done/impl/ui/OnboardingDone.kt @@ -20,10 +20,16 @@ import com.tangem.core.ui.components.* 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.onboarding.v2.done.api.OnboardingDoneComponent import com.tangem.features.onboarding.v2.impl.R +@Suppress("LongMethod") @Composable -internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Modifier) { +internal fun OnboardingDone( + mode: OnboardingDoneComponent.Mode, + onContinueClick: () -> Unit, + modifier: Modifier = Modifier, +) { val composition by rememberLottieComposition(spec = LottieCompositionSpec.RawRes(R.raw.anim_confetti)) val progress by animateLottieCompositionAsState(composition) var showConfetti by remember { mutableStateOf(false) } @@ -57,7 +63,12 @@ internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Mo SpacerH12() Text( modifier = Modifier.padding(horizontal = 32.dp), - text = stringResourceSafe(R.string.onboarding_subtitle_success_tangem_wallet_onboarding), + text = when (mode) { + OnboardingDoneComponent.Mode.WalletCreated -> + stringResourceSafe(R.string.onboarding_subtitle_success_tangem_wallet_onboarding) + OnboardingDoneComponent.Mode.GoodToGo -> + stringResourceSafe(R.string.visa_onboarding_success_screen_description) + }, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, @@ -100,6 +111,9 @@ internal fun OnboardingDone(onContinueClick: () -> Unit, modifier: Modifier = Mo @Composable private fun Preview() { TangemThemePreview { - OnboardingDone(onContinueClick = {}) + OnboardingDone( + mode = OnboardingDoneComponent.Mode.WalletCreated, + onContinueClick = {}, + ) } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt index 8e6cf0d6c5..ae53ea07e1 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt @@ -71,6 +71,7 @@ internal class DefaultOnboardingEntryComponent @AssistedInject constructor( route = configuration, childContext = childByContext(factoryContext), onManageTokensDone = model::onManageTokensDone, + model = model, ) }, ) 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 8deec2de9b..e466f10765 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 @@ -15,15 +15,20 @@ 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 +import com.tangem.domain.wallets.legacy.asLockable import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.biometry.BiometryFeatureToggles import com.tangem.features.onboarding.v2.TitleProvider import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog +import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent +import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent.Mode import com.tangem.features.onboarding.v2.entry.impl.analytics.OnboardingEntryEvent 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.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.NonCancellable @@ -43,6 +48,7 @@ internal class OnboardingEntryModel @Inject constructor( private val askBiometryFeatureToggles: BiometryFeatureToggles, private val analyticsEventHandler: AnalyticsEventHandler, private val uiMessageSender: UiMessageSender, + private val userWalletsListManager: UserWalletsListManager, ) : Model() { private val params = paramsContainer.require() @@ -56,6 +62,7 @@ internal class OnboardingEntryModel @Inject constructor( } val startRoute = routeByProductType(params.scanResponse) + val onboardingTwinModelCallbacks = OnboardingTwinModelCallbacks() fun onManageTokensDone() { navigateToFinalScreenFlow() @@ -66,32 +73,26 @@ internal class OnboardingEntryModel @Inject constructor( } private fun routeByProductType(scanResponse: ScanResponse): OnboardingRoute { - val multiWalletNavigationMode = when (params.multiWalletMode) { - OnboardingEntryComponent.MultiWalletMode.Onboarding -> - OnboardingMultiWalletComponent.Mode.Onboarding - OnboardingEntryComponent.MultiWalletMode.AddBackup -> - OnboardingMultiWalletComponent.Mode.AddBackup - OnboardingEntryComponent.MultiWalletMode.ContinueFinalize -> - OnboardingMultiWalletComponent.Mode.ContinueFinalize - } - return when (scanResponse.productType) { - ProductType.Wallet -> OnboardingRoute.MultiWallet( - scanResponse = scanResponse, - withSeedPhraseFlow = false, - titleProvider = titleProvider, - onDone = ::onMultiWalletOnboardingDone, - mode = multiWalletNavigationMode, - ) - ProductType.Ring, + ProductType.Wallet, ProductType.Wallet2, - -> OnboardingRoute.MultiWallet( - scanResponse = scanResponse, - withSeedPhraseFlow = true, - titleProvider = titleProvider, - onDone = ::onMultiWalletOnboardingDone, - mode = multiWalletNavigationMode, - ) + ProductType.Ring, + -> { + val multiWalletNavigationMode = when (params.mode) { + Mode.Onboarding -> OnboardingMultiWalletComponent.Mode.Onboarding + Mode.AddBackupWallet1 -> OnboardingMultiWalletComponent.Mode.AddBackup + Mode.ContinueFinalize -> OnboardingMultiWalletComponent.Mode.ContinueFinalize + else -> error("Incorrect onboarding type") + } + + OnboardingRoute.MultiWallet( + scanResponse = scanResponse, + withSeedPhraseFlow = scanResponse.productType != ProductType.Wallet, + titleProvider = titleProvider, + onDone = ::onMultiWalletOnboardingDone, + mode = multiWalletNavigationMode, + ) + } ProductType.Visa -> OnboardingRoute.Visa( scanResponse = scanResponse, titleProvider = titleProvider, @@ -102,14 +103,33 @@ internal class OnboardingEntryModel @Inject constructor( titleProvider = titleProvider, onDone = ::navigateToFinalScreenFlow, ) + ProductType.Twins -> { + val mode = when (params.mode) { + Mode.Onboarding -> OnboardingTwinComponent.Params.Mode.CreateWallet + Mode.WelcomeOnlyTwin -> OnboardingTwinComponent.Params.Mode.WelcomeOnly + Mode.RecreateWalletTwin -> OnboardingTwinComponent.Params.Mode.RecreateWallet + else -> error("Incorrect onboarding type") + } + + OnboardingRoute.Twins( + scanResponse = scanResponse, + titleProvider = titleProvider, + mode = mode, + ) + } else -> error("Unsupported") } } private fun onMultiWalletOnboardingDone(userWallet: UserWallet) { when { - params.multiWalletMode == OnboardingEntryComponent.MultiWalletMode.AddBackup -> { - stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen)) + params.mode == Mode.AddBackupWallet1 -> { + stackNavigation.replaceAll( + OnboardingRoute.Done( + mode = OnboardingDoneComponent.Mode.WalletCreated, + onDone = ::exitComponentScreen, + ), + ) } userWallet.scanResponse.cardTypesResolver.isMultiwalletAllowed() -> { stackNavigation.replaceAll(OnboardingRoute.ManageTokens(userWallet)) @@ -121,48 +141,104 @@ internal class OnboardingEntryModel @Inject constructor( } private fun onVisaOnboardingDone() { - navigateToFinalScreenFlow() + navigateToFinalScreenFlow(doneMode = OnboardingDoneComponent.Mode.GoodToGo) } - private fun navigateToFinalScreenFlow() { + private fun navigateToFinalScreenFlow( + doneMode: OnboardingDoneComponent.Mode = OnboardingDoneComponent.Mode.WalletCreated, + ) { if (askBiometryFeatureToggles.isAskForBiometryEnabled) { modelScope.launch { if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) { stackNavigation.replaceAll( - OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks()), + OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks(doneMode)), ) } else { - stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen)) + stackNavigation.replaceAll( + OnboardingRoute.Done( + mode = doneMode, + onDone = ::exitComponentScreen, + ), + ) } } } else { - stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen)) + stackNavigation.replaceAll( + OnboardingRoute.Done( + mode = doneMode, + onDone = ::exitComponentScreen, + ), + ) } } - inner class AskBiometryModelCallbacks : AskBiometryComponent.ModelCallbacks { + inner class AskBiometryModelCallbacks( + private val doneMode: OnboardingDoneComponent.Mode, + ) : AskBiometryComponent.ModelCallbacks { override fun onAllowed() { analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.On)) - stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen)) + stackNavigation.replaceAll( + OnboardingRoute.Done( + mode = doneMode, + onDone = ::exitComponentScreen, + ), + ) } override fun onDenied() { analyticsEventHandler.send(OnboardingEntryEvent.Biometric(OnboardingEntryEvent.Biometric.State.Off)) - stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen)) + stackNavigation.replaceAll( + OnboardingRoute.Done( + mode = doneMode, + onDone = ::exitComponentScreen, + ), + ) } } - private fun navigateToWalletScreen() { + private fun exitComponentScreen() { if (askBiometryFeatureToggles.isAskForBiometryEnabled) { - router.replaceAll(AppRoute.Wallet) - } else { - modelScope.launch(NonCancellable) { - router.replaceAll(AppRoute.Wallet) - if (tangemSdkManager.checkCanUseBiometry() && settingsRepository.shouldShowSaveUserWalletScreen()) { - delay(timeMillis = 1_800) - router.push(AppRoute.SaveWallet) + if (userWalletsListManager.hasUserWallets) { + val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync!! }.getOrElse { false } + + if (isLocked) { + router.replaceAll(AppRoute.Welcome()) + } else { + router.replaceAll(AppRoute.Wallet) } + } else { + router.replaceAll(AppRoute.Home) + } + } else { + if (userWalletsListManager.hasUserWallets) { + val isLocked = runCatching { userWalletsListManager.asLockable()?.isLockedSync!! }.getOrElse { false } + + if (isLocked) { + router.replaceAll(AppRoute.Welcome()) + } else { + modelScope.launch(NonCancellable) { + router.replaceAll(AppRoute.Wallet) + if (tangemSdkManager.checkCanUseBiometry() && + settingsRepository.shouldShowSaveUserWalletScreen() + ) { + delay(timeMillis = 1_800) + router.push(AppRoute.SaveWallet) + } + } + } + } else { + router.replaceAll(AppRoute.Home) } } } + + inner class OnboardingTwinModelCallbacks : OnboardingTwinComponent.ModelCallbacks { + override fun onDone() { + navigateToFinalScreenFlow() + } + + override fun onBack() { + router.pop() + } + } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingChildFactory.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingChildFactory.kt index 608d542ebe..d0e8a13f03 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingChildFactory.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingChildFactory.kt @@ -4,11 +4,14 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.managetokens.component.OnboardingManageTokensComponent import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent +import com.tangem.features.onboarding.v2.entry.impl.model.OnboardingEntryModel import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent +import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent import com.tangem.features.onboarding.v2.visa.api.OnboardingVisaComponent import javax.inject.Inject +@Suppress("LongParameterList") internal class OnboardingChildFactory @Inject constructor( private val multiWalletComponentFactory: OnboardingMultiWalletComponent.Factory, private val onboardingManageTokensComponentFactory: OnboardingManageTokensComponent.Factory, @@ -16,9 +19,15 @@ internal class OnboardingChildFactory @Inject constructor( private val onBoardingVisaComponentFactory: OnboardingVisaComponent.Factory, private val askBiometryComponentFactory: AskBiometryComponent.Factory, private val onboardingNoteComponentFactory: OnboardingNoteComponent.Factory, + private val onboardingTwinComponentFactory: OnboardingTwinComponent.Factory, ) { - fun createChild(route: OnboardingRoute, childContext: AppComponentContext, onManageTokensDone: () -> Unit): Any { + fun createChild( + route: OnboardingRoute, + childContext: AppComponentContext, + model: OnboardingEntryModel, + onManageTokensDone: () -> Unit, + ): Any { return when (route) { is OnboardingRoute.MultiWallet -> multiWalletComponentFactory.create( context = childContext, @@ -48,6 +57,7 @@ internal class OnboardingChildFactory @Inject constructor( is OnboardingRoute.Done -> onboardingDoneComponentFactory.create( context = childContext, params = OnboardingDoneComponent.Params( + mode = route.mode, onDone = route.onDone, ), ) @@ -59,6 +69,15 @@ internal class OnboardingChildFactory @Inject constructor( onDone = route.onDone, ), ) + is OnboardingRoute.Twins -> onboardingTwinComponentFactory.create( + context = childContext, + params = OnboardingTwinComponent.Params( + scanResponse = route.scanResponse, + modelCallbacks = model.onboardingTwinModelCallbacks, + titleProvider = route.titleProvider, + mode = route.mode, + ), + ) is OnboardingRoute.AskBiometry -> askBiometryComponentFactory.create( context = childContext, params = AskBiometryComponent.Params( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingRoute.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingRoute.kt index b111c82d91..4d0b03129a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingRoute.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/routing/OnboardingRoute.kt @@ -5,7 +5,9 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.onboarding.v2.TitleProvider +import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent sealed class OnboardingRoute : Route { @@ -31,6 +33,12 @@ sealed class OnboardingRoute : Route { val onDone: () -> Unit, ) : OnboardingRoute() + data class Twins( + val titleProvider: TitleProvider, + val scanResponse: ScanResponse, + val mode: OnboardingTwinComponent.Params.Mode, + ) : OnboardingRoute() + data class ManageTokens( val userWallet: UserWallet, ) : OnboardingRoute() @@ -40,6 +48,7 @@ sealed class OnboardingRoute : Route { ) : OnboardingRoute() data class Done( + val mode: OnboardingDoneComponent.Mode, val onDone: () -> Unit, ) : OnboardingRoute() } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index b7d96ab79a..ff138a6d70 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -26,7 +26,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigation import com.tangem.core.decompose.navigation.inner.InnerNavigationState import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent -import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.MultiWalletAccessCodeComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/model/MultiWalletAccessCodeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/model/MultiWalletAccessCodeModel.kt index bd4a37a0b5..a432deb8d0 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/model/MultiWalletAccessCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/model/MultiWalletAccessCodeModel.kt @@ -6,7 +6,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.ui.state.MultiWalletAccessCodeUM import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/ui/MultiWalletAccessCodeBS.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/ui/MultiWalletAccessCodeBS.kt index 8ac18706c6..c7b634eeb7 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/ui/MultiWalletAccessCodeBS.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/ui/MultiWalletAccessCodeBS.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.extensions.stringResourceSafe @@ -36,7 +36,7 @@ private fun Content(state: MultiWalletAccessCodeUM, modifier: Modifier = Modifie modifier = Modifier.weight(1f), targetState = state.step, transitionSpec = TangemAnimations.AnimatedContent - .slide { initial, target -> initial.ordinal > target.ordinal }, + .slide { initial, target -> target.ordinal > initial.ordinal }, label = "AnimatedContent", ) { step -> when (step) { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/ui/MultiWalletAccessCodeIntro.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/ui/MultiWalletAccessCodeIntro.kt index 3f0fe0f290..2e6166a337 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/ui/MultiWalletAccessCodeIntro.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/accesscode/ui/MultiWalletAccessCodeIntro.kt @@ -31,7 +31,7 @@ internal fun MultiWalletAccessCodeIntro(modifier: Modifier = Modifier) { color = TangemTheme.colors.text.primary1, ) Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_access_code), + imageVector = ImageVector.vectorResource(R.drawable.ic_access_code_119), tint = TangemTheme.colors.icon.informative, contentDescription = null, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/model/MultiWalletBackupModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/model/MultiWalletBackupModel.kt index 6728f170e4..e9be6ab78d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/model/MultiWalletBackupModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/model/MultiWalletBackupModel.kt @@ -1,11 +1,12 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.model import androidx.compose.runtime.Stable -import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.common.CompletionResult import com.tangem.common.core.TangemSdkError import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -19,8 +20,8 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ProductType +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.impl.R -import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.ui.backupCardAttestationFailedDialog @@ -48,6 +49,7 @@ class MultiWalletBackupModel @Inject constructor( private val cardSdkConfigRepository: CardSdkConfigRepository, private val tangemSdkManager: TangemSdkManager, private val analyticsEventHandler: AnalyticsEventHandler, + private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val uiMessageSender: UiMessageSender, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val cardRepository: CardRepository, @@ -241,7 +243,7 @@ class MultiWalletBackupModel @Inject constructor( ) } } - else -> FirebaseCrashlytics.getInstance().recordException(result.error) + else -> analyticsExceptionHandler.sendException(ExceptionAnalyticsEvent(result.error)) } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/model/Wallet1ChooseOptionModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/model/Wallet1ChooseOptionModel.kt index c460852063..c5cd73b286 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/model/Wallet1ChooseOptionModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/model/Wallet1ChooseOptionModel.kt @@ -10,9 +10,8 @@ import com.tangem.domain.common.TapWorkarounds.canSkipBackup import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase -import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableSharedFlow @@ -25,7 +24,7 @@ import javax.inject.Inject internal class Wallet1ChooseOptionModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, paramsContainer: ParamsContainer, - private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, private val cardRepository: CardRepository, private val saveWalletUseCase: SaveWalletUseCase, private val analyticsHandler: AnalyticsEventHandler, @@ -67,7 +66,7 @@ internal class Wallet1ChooseOptionModel @Inject constructor( private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet { return requireNotNull( - value = UserWalletBuilder(scanResponse, generateWalletNameUseCase).build(), + value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(), lazyMessage = { "User wallet not created" }, ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt index 235a5a45ff..86226a42b6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/model/MultiWalletCreateWalletModel.kt @@ -15,10 +15,9 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.impl.R -import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.state.MultiWalletCreateWalletUM import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog @@ -43,7 +42,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor( private val getCardInfoUseCase: GetCardInfoUseCase, private val cardRepository: CardRepository, private val analyticsHandler: AnalyticsEventHandler, - private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, private val saveWalletUseCase: SaveWalletUseCase, ) : Model() { @@ -146,7 +145,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor( private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet { return requireNotNull( - value = UserWalletBuilder(scanResponse, generateWalletNameUseCase).build(), + value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(), lazyMessage = { "User wallet not created" }, ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/ui/state/MultiWalletCreateWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/ui/state/MultiWalletCreateWalletUM.kt index 6f1af9d0a3..2a6a97474f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/ui/state/MultiWalletCreateWalletUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/ui/state/MultiWalletCreateWalletUM.kt @@ -1,7 +1,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.state import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM +import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM internal data class MultiWalletCreateWalletUM( val title: TextReference, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index a615402867..bfdae378fb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -20,7 +20,6 @@ import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent @@ -49,7 +48,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( private val tangemSdkManager: TangemSdkManager, private val getCardInfoUseCase: GetCardInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, - private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, private val userWalletsListManager: UserWalletsListManager, private val cardRepository: CardRepository, private val onboardingRepository: OnboardingRepository, @@ -281,7 +280,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet { return requireNotNull( - value = UserWalletBuilder(scanResponse, generateWalletNameUseCase) + value = userWalletBuilderFactory.create(scanResponse = scanResponse) .backupCardsIds(backupCardIds.toSet()) .hasBackupError(walletHasBackupError) .build(), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/state/MultiWalletFinalizeUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/state/MultiWalletFinalizeUM.kt index b367e365b4..00387df368 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/state/MultiWalletFinalizeUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/ui/state/MultiWalletFinalizeUM.kt @@ -1,6 +1,6 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.state -import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM +import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM internal data class MultiWalletFinalizeUM( val step: Step = Step.Primary, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt index 7b75b80c4e..fcb2c47870 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/model/MultiWalletSeedPhraseModel.kt @@ -13,13 +13,13 @@ import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType -import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.GenerateSeedPhraseUiStateBuilder import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.ImportSeedPhraseUiStateBuilder import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.SeedPhraseCheckUiStateBuilder import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM -import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.resetCardDialog import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhrase.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhrase.kt index 3ac5e6a920..985a7b68e4 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhrase.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/MultiWalletSeedPhrase.kt @@ -12,7 +12,7 @@ internal fun MultiWalletSeedPhrase(state: MultiWalletSeedPhraseUM, modifier: Mod AnimatedContent( modifier = modifier.navigationBarsPadding(), targetState = state, - transitionSpec = TangemAnimations.AnimatedContent.slide { from, to -> to.order < from.order }, + transitionSpec = TangemAnimations.AnimatedContent.slide { from, to -> to.order > from.order }, contentKey = { st -> st::class.java }, label = "animatedContent", ) { st -> diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/PassphraseInfoBottomSheet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/PassphraseInfoBottomSheet.kt index 99c65af89b..a01530b02b 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/PassphraseInfoBottomSheet.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/PassphraseInfoBottomSheet.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.extensions.stringResourceSafe diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt index f6dd64808c..c167a59481 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/ui/state/MultiWalletSeedPhraseUM.kt @@ -4,8 +4,8 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.GeneratedWordsType -import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/common/ui/Dialogs.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/common/ui/Dialogs.kt index ecf06866f2..9b1a7be5b1 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/common/ui/Dialogs.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/common/ui/Dialogs.kt @@ -1,6 +1,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.common.ui import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM import com.tangem.features.onboarding.v2.impl.R internal fun resetCardDialog(onConfirm: () -> Unit, dismiss: () -> Unit, onDismissButtonClick: () -> Unit) = @@ -19,20 +20,4 @@ internal fun resetCardDialog(onConfirm: () -> Unit, dismiss: () -> Unit, onDismi onDismissButtonClick() dismiss() }, - ) - -internal fun interruptBackupDialog(onConfirm: () -> Unit, dismiss: () -> Unit) = OnboardingDialogUM( - title = resourceReference(R.string.onboarding_exit_alert_title), - message = resourceReference(R.string.onboarding_exit_alert_message), - confirmButtonText = resourceReference(R.string.common_ok), - dismissButtonText = resourceReference(R.string.common_cancel), - dismissWarningColor = true, - onDismiss = dismiss, - onConfirmClick = { - onConfirm() - dismiss() - }, - onDismissButtonClick = { - dismiss() - }, -) \ No newline at end of file + ) \ No newline at end of file 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 c7d7b2963a..2a35bf160f 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 @@ -5,34 +5,40 @@ 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.decompose.ui.UiMessageSender import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ProductType import com.tangem.domain.onboarding.repository.OnboardingRepository import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent -import com.tangem.features.onboarding.v2.multiwallet.impl.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent +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.common.ui.interruptBackupDialog 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.backup.BackupService import com.tangem.sdk.api.BackupServiceHolder import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class OnboardingMultiWalletModel @Inject constructor( paramsContainer: ParamsContainer, + analyticsHandler: AnalyticsEventHandler, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, - private val analyticsHandler: AnalyticsEventHandler, private val backupServiceHolder: BackupServiceHolder, private val onboardingRepository: OnboardingRepository, + private val getCardImageUseCase: GetCardImageUseCase, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() - private val getCardImageUseCase = GetCardImageUseCase() private val _uiState = MutableStateFlow(OnboardingMultiWalletUM()) val state = MutableStateFlow( @@ -56,19 +62,16 @@ internal class OnboardingMultiWalletModel @Inject constructor( } fun onBack() { - _uiState.update { st -> - st.copy( - dialog = interruptBackupDialog( - onConfirm = { - modelScope.launch { - onboardingRepository.clearUnfinishedFinalizeOnboarding() - router.pop() - } - }, - dismiss = { _uiState.update { it.copy(dialog = null) } }, - ), - ) - } + uiMessageSender.send( + interruptBackupDialog( + onConfirm = { + modelScope.launch { + onboardingRepository.clearUnfinishedFinalizeOnboarding() + router.pop() + } + }, + ), + ) } private fun subscribeToBackups() { 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 62bc6aaf7a..4af97756ab 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 @@ -8,9 +8,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButtonUM -import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMultiWalletUM @@ -23,23 +20,6 @@ internal fun OnboardingMultiWallet( modifier: Modifier = Modifier, childContent: @Composable (Modifier) -> Unit = {}, ) { - if (state.dialog != null) { - BasicDialog( - title = state.dialog.title.resolveReference(), - message = state.dialog.message.resolveReference(), - confirmButton = DialogButtonUM( - title = state.dialog.confirmButtonText.resolveReference(), - onClick = state.dialog.onConfirmClick, - ), - dismissButton = DialogButtonUM( - title = state.dialog.dismissButtonText.resolveReference(), - warning = state.dialog.dismissWarningColor, - onClick = state.dialog.onDismissButtonClick, - ), - onDismissDialog = state.dialog.onDismiss, - ) - } - Column( modifier = modifier.fillMaxSize(), ) { 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 5aa205dc81..829828761d 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,10 +1,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.ui.state -import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM - internal data class OnboardingMultiWalletUM( val artwork1Url: String? = null, val artwork2Url: String? = null, val artwork3Url: String? = null, - val dialog: OnboardingDialogUM? = null, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt index d3ca96fa30..bea41ef278 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt @@ -25,15 +25,16 @@ import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent import com.tangem.features.onboarding.v2.note.impl.child.create.OnboardingNoteCreateWalletComponent import com.tangem.features.onboarding.v2.note.impl.child.topup.OnboardingNoteTopUpComponent import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteModel +import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteCommonState import com.tangem.features.onboarding.v2.note.impl.route.ONBOARDING_NOTE_STEPS_COUNT import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch internal class DefaultOnboardingNoteComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @@ -47,6 +48,7 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor( } private val childParams = ChildParams( + commonState = model.commonUiState, onBack = ::onChildBack, parentBackEvent = currentChildBackEventHandle, ) @@ -70,15 +72,13 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor( override val state: StateFlow = model.innerNavigationState override fun pop(onComplete: (Boolean) -> Unit) { - // TODO [REDACTED_TASK_KEY] + model.onChildBack( + currentRoute = childStack.value.active.configuration, + ) } } init { - componentScope.launch { - model.onDone.collect { params.onDone() } - } - // sets title and stepper value childStack.subscribe(lifecycle) { stack -> val currentRoute = stack.active.configuration @@ -96,14 +96,17 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor( appComponentContext = factoryContext, params = OnboardingNoteCreateWalletComponent.Params( childParams = childParams, - onDone = { model.stackNavigation.push(OnboardingNoteRoute.TopUp) }, + onWalletCreated = { userWallet -> + model.onWalletCreated(userWallet) + model.stackNavigation.push(OnboardingNoteRoute.TopUp) + }, ), ) OnboardingNoteRoute.TopUp -> OnboardingNoteTopUpComponent( appComponentContext = factoryContext, params = OnboardingNoteTopUpComponent.Params( childParams = childParams, - onDone = { model.onDone.tryEmit(Unit) }, + onDone = { params.onDone() }, ), ) } @@ -134,6 +137,7 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor( } data class ChildParams( + val commonState: MutableStateFlow, val onBack: () -> Unit, val parentBackEvent: SharedFlow, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/OnboardingNoteCreateWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/OnboardingNoteCreateWalletComponent.kt index 5751eb80ba..603ce3d688 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/OnboardingNoteCreateWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/OnboardingNoteCreateWalletComponent.kt @@ -9,6 +9,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.domain.wallets.models.UserWallet import com.tangem.features.onboarding.v2.note.impl.DefaultOnboardingNoteComponent import com.tangem.features.onboarding.v2.note.impl.child.create.model.OnboardingNoteCreateWalletModel import com.tangem.features.onboarding.v2.note.impl.child.create.ui.OnboardingNoteCreateWallet @@ -24,7 +25,7 @@ internal class OnboardingNoteCreateWalletComponent( override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() - BackHandler(onBack = remember(this) { { params.childParams.onBack() } }) + BackHandler(onBack = remember(this) { { params.childParams.onBack } }) OnboardingNoteCreateWallet( modifier = modifier, @@ -34,6 +35,6 @@ internal class OnboardingNoteCreateWalletComponent( data class Params( val childParams: DefaultOnboardingNoteComponent.ChildParams, - val onDone: () -> Unit, + val onWalletCreated: (UserWallet) -> 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/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 93634beb79..59f7b2e7ab 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 @@ -1,26 +1,103 @@ package com.tangem.features.onboarding.v2.note.impl.child.create.model +import com.tangem.common.CompletionResult +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.domain.card.repository.CardRepository +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.wallets.builder.UserWalletBuilder +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.note.impl.child.create.OnboardingNoteCreateWalletComponent import com.tangem.features.onboarding.v2.note.impl.child.create.ui.state.OnboardingNoteCreateWalletUM import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("UnusedPrivateMember") @ModelScoped internal class OnboardingNoteCreateWalletModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, + private val cardRepository: CardRepository, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, + private val saveWalletUseCase: SaveWalletUseCase, ) : Model() { + private val params = paramsContainer.require() + private val _uiState = MutableStateFlow( - OnboardingNoteCreateWalletUM(), + OnboardingNoteCreateWalletUM( + onCreateClick = ::onCreateWalletClick, + ), ) + init { + Analytics.send(OnboardingEvent.CreateWallet.ScreenOpened) + modelScope.launch { + val scanResponse = params.childParams.commonState.value.scanResponse ?: return@launch + if (!cardRepository.isActivationStarted(scanResponse.card.cardId)) { + Analytics.send(OnboardingEvent.Started) + } + } + observeArtwork() + } + val uiState: StateFlow = _uiState + + private fun onCreateWalletClick() { + modelScope.launch { + _uiState.update { + it.copy(createWalletInProgress = true) + } + val scanResponse = params.childParams.commonState.value.scanResponse ?: return@launch + val result = tangemSdkManager.createProductWallet(scanResponse) + when (result) { + is CompletionResult.Success -> { + Analytics.send(OnboardingEvent.CreateWallet.WalletCreatedSuccessfully()) + cardRepository.startCardActivation(scanResponse.card.cardId) + createWalletAndNavigateBackWithDone(scanResponse.copy(card = result.data.card)) + } + is CompletionResult.Failure -> _uiState.update { + it.copy(createWalletInProgress = false) + } + } + } + } + + private fun createWalletAndNavigateBackWithDone(scanResponse: ScanResponse) { + modelScope.launch { + val userWallet = createUserWallet(scanResponse) + saveWalletUseCase(userWallet, canOverride = true).onRight { + params.onWalletCreated(userWallet) + } + _uiState.update { + it.copy(createWalletInProgress = false) + } + } + } + + private suspend fun createUserWallet(scanResponse: ScanResponse): UserWallet { + return requireNotNull( + value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(), + lazyMessage = { "User wallet not created" }, + ) + } + + private fun observeArtwork() { + modelScope.launch { + params.childParams.commonState.collect { + _uiState.value = _uiState.value.copy( + artworkUrl = it.cardArtworkUrl, + ) + } + } + } } \ 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/ui/OnboardingNoteCreateWallet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt similarity index 89% rename from features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWallet.kt rename to features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt index eba5be11b9..f12d83941a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWallet.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/OnboardingNoteCreateWalletScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerHMax import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -34,20 +36,20 @@ internal fun OnboardingNoteCreateWallet(state: OnboardingNoteCreateWalletUM, mod url2 = null, url3 = null, modifier = Modifier - .padding(horizontal = 34.dp) - .padding(top = 56.dp) + .padding(top = 64.dp) + .padding(horizontal = 24.dp) .weight(ALL_STEPS_TOP_CONTAINER_WEIGHT) .fillMaxWidth(), state = WalletArtworksState.Folded, ) Column( - Modifier + modifier = Modifier.weight(1 - ALL_STEPS_TOP_CONTAINER_WEIGHT) .fillMaxWidth() .verticalScroll(rememberScrollState()) - .padding(32.dp) - .weight(1.0f), + .padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + SpacerHMax() Text( text = stringResource(R.string.onboarding_create_wallet_header), style = TangemTheme.typography.h2, @@ -55,7 +57,7 @@ internal fun OnboardingNoteCreateWallet(state: OnboardingNoteCreateWalletUM, mod textAlign = TextAlign.Center, modifier = Modifier.padding(top = 16.dp), ) - + SpacerH16() Text( text = stringResource(R.string.onboarding_create_wallet_body), style = TangemTheme.typography.body1, @@ -63,6 +65,7 @@ internal fun OnboardingNoteCreateWallet(state: OnboardingNoteCreateWalletUM, mod textAlign = TextAlign.Center, modifier = Modifier.padding(top = 12.dp), ) + SpacerHMax() } PrimaryButtonIconEnd( 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 f2a8e3bf60..021509cd04 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 @@ -1,24 +1,246 @@ package com.tangem.features.onboarding.v2.note.impl.child.topup.model +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +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.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.onramp.GetLegacyTopUpUrlUseCase +import com.tangem.domain.tokens.FetchCurrencyStatusUseCase +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.NetworkAddress +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent +import com.tangem.domain.wallets.builder.UserWalletBuilder +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.note.impl.child.topup.OnboardingNoteTopUpComponent import com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state.OnboardingNoteTopUpUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import com.tangem.utils.extensions.isPositive +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("UnusedPrivateMember") +@Suppress("LongParameterList") @ModelScoped internal class OnboardingNoteTopUpModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, + private val getLegacyTopUpUrlUseCase: GetLegacyTopUpUrlUseCase, + private val urlOpener: UrlOpener, + private val clipboardManager: ClipboardManager, + private val shareManager: ShareManager, + private val rampStateManager: RampStateManager, + private val cardRepository: CardRepository, + private val saveWalletUseCase: SaveWalletUseCase, ) : Model() { + private val params = paramsContainer.require() + private val commonState = params.childParams.commonState + private val scanResponse = params.childParams.commonState.value.scanResponse + private var userWallet = params.childParams.commonState.value.userWallet + private val _uiState = MutableStateFlow( - OnboardingNoteTopUpUM(), + OnboardingNoteTopUpUM( + onRefreshBalanceClick = ::refreshBalance, + onBuyCryptoClick = ::onBuyCryptoClick, + onShowWalletAddressClick = ::onShowWalletAddressClick, + onDismissBottomSheet = ::onDismissBottomSheet, + ), ) val uiState: StateFlow = _uiState + + init { + Analytics.send(OnboardingEvent.Topup.ScreenOpened) + observeArtwork() + modelScope.launch { + createUserWalletIfNull() + cardRepository.finishCardActivation(scanResponse.card.cardId) + observeCryptoCurrencyStatus() + refreshBalance() + } + } + + private fun refreshBalance() { + modelScope.launch { + showBalanceLoadingProgress(true) + createUserWalletIfNull() + val userWalletId = requireNotNull(userWallet?.walletId) + fetchCurrencyStatusUseCase( + userWalletId = userWalletId, + refresh = true, + ) + showBalanceLoadingProgress(false) + } + } + + private fun onBuyCryptoClick() { + val cryptoCurrencyStatus = params.childParams.commonState.value.cryptoCurrencyStatus ?: return + modelScope.launch { + getLegacyTopUpUrlUseCase(cryptoCurrencyStatus).onRight { + urlOpener.openUrl(it) + } + } + Analytics.send(OnboardingEvent.Topup.ButtonBuyCrypto(cryptoCurrencyStatus.currency)) + } + + private fun onShowWalletAddressClick() { + val currencyStatus = params.childParams.commonState.value.cryptoCurrencyStatus ?: return + val networkAddress = currencyStatus.value.networkAddress ?: return + + _uiState.update { + it.copy(addressBottomSheetConfig = createReceiveBS(currencyStatus, networkAddress)) + } + Analytics.send(OnboardingEvent.Topup.ButtonShowWalletAddress) + } + + private fun onDismissBottomSheet() { + _uiState.update { + it.copy(addressBottomSheetConfig = null) + } + } + + private suspend fun createUserWalletIfNull() { + if (userWallet != null) { + return + } + val commonState = params.childParams.commonState.value + userWallet = commonState.userWallet ?: createAndSaveUserWallet(scanResponse) + } + + private fun observeArtwork() { + modelScope.launch { + params.childParams.commonState.collect { + _uiState.value = _uiState.value.copy( + cardArtworkUrl = it.cardArtworkUrl, + ) + } + } + } + + private fun observeCryptoCurrencyStatus() { + val userWalletId = userWallet?.walletId ?: return + getPrimaryCurrencyStatusUpdatesUseCase(userWalletId = userWalletId).map { it.getOrNull() }.filterNotNull() + .onEach(::applyCryptoCurrencyStatusToState).launchIn(modelScope) + } + + private fun applyCryptoCurrencyStatusToState(status: CryptoCurrencyStatus) { + if (commonState.value.cryptoCurrencyStatus == null) { + loadAvailableForBuy(status) + } + + commonState.update { + it.copy(cryptoCurrencyStatus = status) + } + + val amount = when (status.value) { + is CryptoCurrencyStatus.Loaded -> status.value.amount + is CryptoCurrencyStatus.NoAccount -> status.value.amount + is CryptoCurrencyStatus.NoQuote -> status.value.amount + else -> null + } + val hasCurrentNetworkTransactions = when (status.value) { + is CryptoCurrencyStatus.Loaded -> status.value.hasCurrentNetworkTransactions + is CryptoCurrencyStatus.NoAccount -> status.value.hasCurrentNetworkTransactions + else -> false + } + val amountToCreateAccount = (status.value as? CryptoCurrencyStatus.NoAccount)?.amountToCreateAccount + + if (amount?.isPositive() == true || hasCurrentNetworkTransactions) { + params.onDone() + } + + _uiState.update { + it.copy( + amountToCreateAccount = amountToCreateAccount + ?.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + balance = amount?.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }.orEmpty(), + isTopUpDataLoading = status.value.networkAddress == null, + ) + } + } + + private fun showBalanceLoadingProgress(value: Boolean) { + _uiState.update { + it.copy(isRefreshing = value) + } + } + + private fun loadAvailableForBuy(cryptoCurrencyStatus: CryptoCurrencyStatus) { + val userWalletId = userWallet?.walletId ?: return + + modelScope.launch { + val availableForBuy = rampStateManager.availableForBuy( + scanResponse = scanResponse, + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrencyStatus.currency, + ) + _uiState.update { + it.copy( + availableForBuy = availableForBuy == ScenarioUnavailabilityReason.None, + availableForBuyLoading = false, + ) + } + } + } + + private fun createReceiveBS(currencyStatus: CryptoCurrencyStatus, networkAddress: NetworkAddress) = + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = uiState.value.onDismissBottomSheet, + content = TokenReceiveBottomSheetConfig( + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currencyStatus.currency.name, + symbol = currencyStatus.currency.symbol, + ), + network = currencyStatus.currency.network, + networkAddress = networkAddress, + showMemoDisclaimer = + currencyStatus.currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, + onCopyClick = { + Analytics.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(currencyStatus.currency.symbol)) + clipboardManager.setText(text = it, isSensitive = true) + }, + onShareClick = { + Analytics.send(TokenReceiveAnalyticsEvent.ButtonShareAddress(currencyStatus.currency.symbol)) + shareManager.shareText(text = it) + }, + ), + ) + + private suspend fun createAndSaveUserWallet(scanResponse: ScanResponse): UserWallet { + val wallet = requireNotNull( + value = userWalletBuilderFactory.create(scanResponse = scanResponse).build(), + lazyMessage = { "User wallet not created" }, + ) + saveWalletUseCase(wallet, false) + return wallet + } } \ 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/ui/OnboardinNoteTopUpHeader.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardinNoteTopUpHeader.kt deleted file mode 100644 index e0c96f997b..0000000000 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardinNoteTopUpHeader.kt +++ /dev/null @@ -1,125 +0,0 @@ -package com.tangem.features.onboarding.v2.note.impl.child.topup.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -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.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.onboarding.v2.common.ui.WalletCard -import com.tangem.features.onboarding.v2.impl.R - -@Composable -fun OnboardinNoteTopUpHeader( - balance: String?, - cardArtworkUrl: String?, - isRefreshing: Boolean, - onRefreshBalanceClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .padding(vertical = 24.dp, horizontal = 32.dp) - .fillMaxSize() - .background( - TangemTheme.colors.button.secondary, - shape = TangemTheme.shapes.roundedCornersMedium, - ), - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 32.dp), - ) { - WalletCard( - modifier = Modifier.width(120.dp), - url = cardArtworkUrl, - ) - SpacerHMax() - Text( - text = stringResourceSafe(R.string.common_balance_title), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - textAlign = TextAlign.Center, - ) - SpacerH8() - Text( - text = balance.orEmpty(), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - ) - SpacerHMax() - RefreshButton( - isRefreshing = isRefreshing, - onRefreshBalanceClick = onRefreshBalanceClick, - ) - } - } -} - -@Composable -private fun RefreshButton(isRefreshing: Boolean, onRefreshBalanceClick: () -> Unit, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(TangemTheme.dimens.size48) - .shadow(elevation = 2.dp, shape = CircleShape) - .background(TangemTheme.colors.background.action) - .clickable( - enabled = !isRefreshing, - onClick = onRefreshBalanceClick, - indication = ripple(), - interactionSource = remember { MutableInteractionSource() }, - ), - ) { - if (isRefreshing) { - Box { - CircularProgressIndicator( - modifier = Modifier.padding(TangemTheme.dimens.spacing12), - color = TangemTheme.colors.icon.primary1, - strokeWidth = TangemTheme.dimens.size2, - ) - } - } else { - Icon( - painter = painterResource(id = R.drawable.ic_refresh_24), - contentDescription = null, - tint = TangemTheme.colors.text.disabled, - modifier = Modifier.padding(TangemTheme.dimens.spacing12), - ) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun OnboardinNoteTopUpHeaderPreview() { - TangemThemePreview { - OnboardinNoteTopUpHeader( - balance = "0.00000001 BTC", - cardArtworkUrl = "", - onRefreshBalanceClick = {}, - isRefreshing = false, - ) - } -} \ 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/ui/OnboardingNoteTopUpHeader.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt new file mode 100644 index 0000000000..64125adc75 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpHeader.kt @@ -0,0 +1,101 @@ +package com.tangem.features.onboarding.v2.note.impl.child.topup.ui + +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.text.style.TextAlign +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.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalTangemShimmer +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +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 + +@Composable +fun OnboardingNoteTopUpHeader( + balance: String, + cardArtworkUrl: String?, + isRefreshing: Boolean, + onRefreshBalanceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .heightIn(min = 180.dp) + .widthIn(max = 450.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .padding(vertical = 24.dp, horizontal = 16.dp) + .fillMaxSize() + .background( + TangemTheme.colors.button.secondary, + shape = TangemTheme.shapes.roundedCornersMedium, + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + SpacerHMax() + Text( + text = stringResourceSafe(R.string.common_balance_title), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + modifier = if (balance.isEmpty()) { + Modifier + .width(120.dp) + .clip(RoundedCornerShape(size = TangemTheme.dimens.radius3)) + .shimmer(LocalTangemShimmer.current) + } else { + Modifier + }, + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + text = balance, + ) + SpacerHMax() + } + } + WalletCard( + modifier = Modifier.width(120.dp).align(Alignment.TopCenter), + url = cardArtworkUrl, + ) + RefreshButton( + modifier = Modifier.align(Alignment.BottomCenter), + isRefreshing = isRefreshing, + onRefreshBalanceClick = onRefreshBalanceClick, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun OnboardinNoteTopUpHeaderPreview() { + TangemThemePreview { + OnboardingNoteTopUpHeader( + balance = "0.00000001 BTC", + cardArtworkUrl = "", + onRefreshBalanceClick = {}, + isRefreshing = false, + ) + } +} \ 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/ui/OnboardingNoteTopUp.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt similarity index 65% rename from features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUp.kt rename to features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt index a850b14829..b7bee01049 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUp.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/OnboardingNoteTopUpScreen.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.note.impl.child.topup.ui +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -10,9 +11,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton -import com.tangem.core.ui.components.SpacerH8 +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerHMax import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -27,26 +30,27 @@ fun OnboardingNoteTopUp(state: OnboardingNoteTopUpUM, modifier: Modifier = Modif .fillMaxSize() .navigationBarsPadding(), verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.CenterHorizontally, ) { - OnboardinNoteTopUpHeader( + OnboardingNoteTopUpHeader( balance = state.balance, cardArtworkUrl = state.cardArtworkUrl, onRefreshBalanceClick = state.onRefreshBalanceClick, isRefreshing = state.isRefreshing, modifier = Modifier - .padding(horizontal = 34.dp) - .padding(top = 20.dp) + .padding(top = 64.dp) + .padding(horizontal = 24.dp) .weight(ALL_STEPS_TOP_CONTAINER_WEIGHT) .fillMaxWidth(), ) Column( - modifier = Modifier + modifier = Modifier.weight(1 - ALL_STEPS_TOP_CONTAINER_WEIGHT) .fillMaxWidth() .verticalScroll(rememberScrollState()) - .padding(32.dp) - .weight(1.0f), + .padding(horizontal = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + SpacerHMax() Text( text = stringResourceSafe(R.string.onboarding_topup_title), style = TangemTheme.typography.h2, @@ -55,48 +59,62 @@ fun OnboardingNoteTopUp(state: OnboardingNoteTopUpUM, modifier: Modifier = Modif modifier = Modifier.padding(top = 16.dp), ) + val text = if (state.amountToCreateAccount != null) { + stringResourceSafe( + R.string.onboarding_top_up_min_create_account_amount, + state.amountToCreateAccount, + ) + } else { + stringResourceSafe(R.string.onboarding_top_up_body) + } + SpacerH16() Text( - text = stringResourceSafe(R.string.onboarding_top_up_body), + text = text, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 12.dp), ) + SpacerHMax() } BottomButtons(state) + + state.addressBottomSheetConfig?.let { config -> + TokenReceiveBottomSheet(config = config) + } } } @Composable private fun BottomButtons(state: OnboardingNoteTopUpUM) { if (state.availableForBuy) { - PrimaryButtonIconEnd( + PrimaryButton( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) .fillMaxWidth(), - iconResId = R.drawable.ic_tangem_24, text = stringResourceSafe(R.string.onboarding_top_up_button_but_crypto), onClick = state.onBuyCryptoClick, ) - SpacerH8() - SecondaryButton( - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .fillMaxWidth(), - text = stringResourceSafe(R.string.onboarding_top_up_button_show_wallet_address), - onClick = state.onShowWalletAddressClick, - ) } else { - PrimaryButtonIconEnd( + PrimaryButton( modifier = Modifier .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .fillMaxWidth(), - iconResId = R.drawable.ic_tangem_24, text = stringResourceSafe(R.string.onboarding_button_receive_crypto), onClick = state.onShowWalletAddressClick, ) } + AnimatedVisibility(visible = !state.availableForBuyLoading) { + if (state.availableForBuy) { + SecondaryButton( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.onboarding_top_up_button_show_wallet_address), + onClick = state.onShowWalletAddressClick, + ) + } + } } @Preview(showBackground = true) 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 762ccae2e8..327de0d586 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,12 +1,18 @@ package com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig + data class OnboardingNoteTopUpUM( val cardArtworkUrl: String? = null, - val availableForBuy: Boolean = true, - val balance: String? = null, + val availableForBuy: Boolean = false, + val availableForBuyLoading: Boolean = true, + val balance: String = "", val isRefreshing: Boolean = false, + val isTopUpDataLoading: Boolean = true, val amountToCreateAccount: String? = null, + val addressBottomSheetConfig: TangemBottomSheetConfig? = null, val onBuyCryptoClick: () -> Unit = {}, val onShowWalletAddressClick: () -> Unit = {}, val onRefreshBalanceClick: () -> Unit = {}, + val onDismissBottomSheet: () -> Unit = {}, ) \ No newline at end of file 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 new file mode 100644 index 0000000000..8ebe5bcd8b --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt @@ -0,0 +1,12 @@ +package com.tangem.features.onboarding.v2.note.impl.model + +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet + +internal data class OnboardingNoteCommonState( + val scanResponse: ScanResponse, + val userWallet: UserWallet? = null, + val cardArtworkUrl: String? = 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 87a00a91e1..30733d3ff8 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 @@ -4,46 +4,54 @@ import com.arkivanov.decompose.router.stack.StackNavigation 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.decompose.ui.UiMessageSender +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.GetCardImageUseCase +import com.tangem.features.onboarding.v2.common.ui.exitOnboardingDialog 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.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped internal class OnboardingNoteModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val messageSender: UiMessageSender, + private val getCardImageUseCase: GetCardImageUseCase, ) : Model() { @Suppress("UnusedPrivateMember") private val params = paramsContainer.require() - private val _currentScanResponse = MutableStateFlow(params.scanResponse) - - val initialRoute = OnboardingNoteRoute.CreateWallet // TODO [REDACTED_TASK_KEY] + val initialRoute = initializeRoute() private val _innerNavigationState = MutableStateFlow(OnboardingNoteInnerNavigationState(stackSize = initialRoute.stepNum())) - val currentScanResponse = _currentScanResponse.asStateFlow() - val innerNavigationState = _innerNavigationState.asStateFlow() - val onDone = MutableSharedFlow() - val stackNavigation = StackNavigation() private val _uiState = MutableStateFlow( - OnboardingNoteState(), + OnboardingNoteCommonState( + scanResponse = params.scanResponse, + ), ) - val uiState: StateFlow = _uiState + val commonUiState: MutableStateFlow = _uiState + + init { + loadArtworkUrl() + } fun updateStepForNewRoute(route: OnboardingNoteRoute) { _innerNavigationState.value = innerNavigationState.value.copy( @@ -53,6 +61,40 @@ internal class OnboardingNoteModel @Inject constructor( @Suppress("UnusedPrivateMember") fun onChildBack(currentRoute: OnboardingNoteRoute) { - // TODO [REDACTED_TASK_KEY] + messageSender.send( + exitOnboardingDialog( + onConfirm = { + router.pop() + }, + ), + ) + } + + fun onWalletCreated(userWallet: UserWallet) { + commonUiState.update { + it.copy(userWallet = userWallet) + } + } + + private fun initializeRoute(): OnboardingNoteRoute { + val card = params.scanResponse.card + return if (card.wallets.isEmpty()) { + OnboardingNoteRoute.CreateWallet + } else { + OnboardingNoteRoute.TopUp + } + } + + private fun loadArtworkUrl() { + modelScope.launch { + val cardInfo = params.scanResponse.card + val url = getCardImageUseCase( + cardId = cardInfo.cardId, + cardPublicKey = cardInfo.cardPublicKey, + ) + _uiState.update { + it.copy(cardArtworkUrl = url) + } + } } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteState.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteState.kt deleted file mode 100644 index 7d183852bb..0000000000 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.onboarding.v2.note.impl.model - -data class OnboardingNoteState( - val cardArtworkUrl: String? = null, -) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/api/OnboardingTwinComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/api/OnboardingTwinComponent.kt new file mode 100644 index 0000000000..2a28cf2e89 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/api/OnboardingTwinComponent.kt @@ -0,0 +1,27 @@ +package com.tangem.features.onboarding.v2.twin.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.features.onboarding.v2.TitleProvider + +interface OnboardingTwinComponent : ComposableContentComponent { + + data class Params( + val scanResponse: ScanResponse, + val modelCallbacks: ModelCallbacks, + val titleProvider: TitleProvider, + val mode: Mode, + ) { + enum class Mode { + WelcomeOnly, CreateWallet, RecreateWallet + } + } + + interface ModelCallbacks { + fun onDone() + fun onBack() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt new file mode 100644 index 0000000000..ded3238c83 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt @@ -0,0 +1,67 @@ +package com.tangem.features.onboarding.v2.twin.impl + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerNavigation +import com.tangem.core.decompose.navigation.inner.InnerNavigationHolder +import com.tangem.core.decompose.navigation.inner.InnerNavigationState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent +import com.tangem.features.onboarding.v2.twin.impl.model.OnboardingTwinModel +import com.tangem.features.onboarding.v2.twin.impl.ui.OnboardingTwin +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.StateFlow + +internal class DefaultOnboardingTwinComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: OnboardingTwinComponent.Params, +) : OnboardingTwinComponent, AppComponentContext by appComponentContext, InnerNavigationHolder { + + private val model: OnboardingTwinModel = getOrCreateModel(params) + + init { + params.titleProvider.changeTitle(resourceReference(R.string.twins_recreate_toolbar)) + } + + override val innerNavigation: InnerNavigation = object : InnerNavigation { + override val state: StateFlow = model.innerNavigationState + + override fun pop(onComplete: (Boolean) -> Unit) { + model.onBack() + } + } + + @Composable + override fun Content(modifier: Modifier) { + BackHandler { model.onBack() } + + val state by model.uiState.collectAsStateWithLifecycle() + + OnboardingTwin( + state = state, + modifier = modifier, + ) + } + + data class TwinInnerNavigationState( + override val stackSize: Int, + ) : InnerNavigationState { + override val stackMaxSize: Int? = 5 + } + + @AssistedFactory + interface Factory : OnboardingTwinComponent.Factory { + override fun create( + context: AppComponentContext, + params: OnboardingTwinComponent.Params, + ): DefaultOnboardingTwinComponent + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/di/ComponentModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/di/ComponentModule.kt new file mode 100644 index 0000000000..41e30634d5 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/di/ComponentModule.kt @@ -0,0 +1,33 @@ +package com.tangem.features.onboarding.v2.twin.impl.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent +import com.tangem.features.onboarding.v2.twin.impl.DefaultOnboardingTwinComponent +import com.tangem.features.onboarding.v2.twin.impl.model.OnboardingTwinModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface ComponentModule { + + @Binds + @Singleton + fun bindComponent(factory: DefaultOnboardingTwinComponent.Factory): OnboardingTwinComponent.Factory +} + +@Module +@InstallIn(ModelComponent::class) +internal interface ModelModule { + + @Binds + @IntoMap + @ClassKey(OnboardingTwinModel::class) + fun provideModel(model: OnboardingTwinModel): Model +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/Dialogs.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/Dialogs.kt new file mode 100644 index 0000000000..a819a60957 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/Dialogs.kt @@ -0,0 +1,34 @@ +package com.tangem.features.onboarding.v2.twin.impl.model + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.features.onboarding.v2.impl.R + +internal val TwinProcessNotCompleted = DialogMessage( + message = resourceReference(R.string.onboarding_twin_exit_warning), + firstAction = EventMessageAction( + title = resourceReference(R.string.warning_button_ok), + onClick = {}, + ), +) + +internal fun cardVerificationFailedDialog( + errorDescription: TextReference, + onRequestSupport: () -> Unit, + onCancelClick: () -> Unit = {}, +): DialogMessage { + return DialogMessage( + message = errorDescription, + title = resourceReference(id = R.string.security_alert_title), + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(id = R.string.alert_button_request_support), + onClick = onRequestSupport, + ) + }, + secondActionBuilder = { cancelAction(onClick = onCancelClick) }, + ) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/OnboardingTwinModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/OnboardingTwinModel.kt new file mode 100644 index 0000000000..1219a5763f --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/model/OnboardingTwinModel.kt @@ -0,0 +1,501 @@ +package com.tangem.features.onboarding.v2.twin.impl.model + +import com.tangem.Message +import com.tangem.common.CompletionResult +import com.tangem.common.KeyPair +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +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.ui.UiMessageSender +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.core.analytics.Analytics +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.toWrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.datasource.local.config.issuers.IssuersConfigStorage +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.common.TwinCardNumber +import com.tangem.domain.common.getTwinCardNumber +import com.tangem.domain.common.util.twinsIsTwinned +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase +import com.tangem.domain.onramp.GetLegacyTopUpUrlUseCase +import com.tangem.domain.tokens.FetchCurrencyStatusUseCase +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.model.analytics.TokenReceiveAnalyticsEvent +import com.tangem.domain.wallets.builder.UserWalletBuilder +import com.tangem.domain.wallets.builder.UserWalletIdBuilder +import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent +import com.tangem.features.onboarding.v2.common.ui.interruptBackupDialog +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent +import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent.Params.Mode +import com.tangem.features.onboarding.v2.twin.impl.DefaultOnboardingTwinComponent +import com.tangem.features.onboarding.v2.twin.impl.ui.TwinWalletArtworkUM +import com.tangem.features.onboarding.v2.twin.impl.ui.state.OnboardingTwinUM +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.sdk.extensions.localizedDescriptionRes +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LongParameterList", "LargeClass") +@ModelScoped +internal class OnboardingTwinModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, + private val userWalletsListManager: UserWalletsListManager, + private val analyticsEventHandler: AnalyticsEventHandler, + private val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase, + private val tangemSdkManager: TangemSdkManager, + private val issuersConfigStorage: IssuersConfigStorage, + private val cardRepository: CardRepository, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, + private val getLegacyTopUpUrlUseCase: GetLegacyTopUpUrlUseCase, + private val urlOpener: UrlOpener, + private val uiMessageSender: UiMessageSender, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val clipboardManager: ClipboardManager, + private val shareManager: ShareManager, +) : Model() { + + private val params = paramsContainer.require() + private val firstCardTwinNumber = params.scanResponse.card.getTwinCardNumber() ?: error("Not twin") + private val cryptoCurrencyStatusJobHolder = JobHolder() + + private val _uiState = MutableStateFlow( + when (params.mode) { + Mode.WelcomeOnly -> { + OnboardingTwinUM.Welcome( + pairCardNumber = firstCardTwinNumber.pairNumber().number, + onContinueClick = ::saveWalletAndDone, + ) + } + Mode.RecreateWallet -> { + OnboardingTwinUM.ResetWarning( + onAcceptClick = { toggle -> + update { it.copy(acceptToggle = toggle) } + }, + onContinueClick = { + navigateToFirstScan() + }, + ) + } + Mode.CreateWallet -> { + if (params.scanResponse.twinsIsTwinned()) { + OnboardingTwinUM.TopUpPrepare + } else { + OnboardingTwinUM.Welcome( + pairCardNumber = firstCardTwinNumber.pairNumber().number, + onContinueClick = ::navigateToFirstScan, + ) + } + } + }, + ) + + val uiState = _uiState.asStateFlow() + val innerNavigationState = MutableStateFlow(DefaultOnboardingTwinComponent.TwinInnerNavigationState(1)) + + init { + when (_uiState.value) { + is OnboardingTwinUM.Welcome -> { + analyticsEventHandler.send(OnboardingEvent.Twins.ScreenOpened) + modelScope.launch { + saveTwinsOnboardingShownUseCase() + } + } + OnboardingTwinUM.TopUpPrepare -> { + modelScope.launch { + setTopUpState(params.scanResponse) + } + } + else -> {} + } + } + + fun onBack() { + val state = _uiState.value + val twinningInProgress = state is OnboardingTwinUM.ScanCard && + (state.step == OnboardingTwinUM.ScanCard.Step.Second || state.step == OnboardingTwinUM.ScanCard.Step.Third) + + if (twinningInProgress) { + uiMessageSender.send(TwinProcessNotCompleted) + } else { + uiMessageSender.send( + interruptBackupDialog( + onConfirm = { params.modelCallbacks.onBack() }, + ), + ) + } + } + + private fun navigateToFirstScan() { + _uiState.value = OnboardingTwinUM.ScanCard( + artworkStep = when (firstCardTwinNumber) { + TwinCardNumber.First -> TwinWalletArtworkUM.Leapfrog.Step.FirstCard + TwinCardNumber.Second -> TwinWalletArtworkUM.Leapfrog.Step.SecondCard + }, + step = OnboardingTwinUM.ScanCard.Step.First, + onScanClick = { createFirstWallet() }, + ) + } + + private fun createFirstWallet() { + modelScope.launch { + setLoading(true) + + val result = tangemSdkManager.createFirstTwinWallet( + cardId = params.scanResponse.card.cardId, + initialMessage = Message( + tangemSdkManager.getString( + R.string.twins_recreate_title_format, + firstCardTwinNumber.number, + ), + ), + ) + + when (result) { + is CompletionResult.Failure -> { + setLoading(false) + showCardVerificationFailedDialog(result.error) + } + is CompletionResult.Success -> { + if (params.mode == Mode.CreateWallet) { + cardRepository.startCardActivation(result.data.cardId) + } + + // remove wallet only after first step of retwin + if (params.mode == Mode.RecreateWallet) { + userWalletsListManager.delete( + listOfNotNull(UserWalletIdBuilder.scanResponse(params.scanResponse).build()), + ) + } + + analyticsEventHandler.send(OnboardingEvent.CreateWallet.WalletCreatedSuccessfully()) + + update { + it.copy( + isLoading = false, + artworkStep = it.artworkStep.next(), + step = OnboardingTwinUM.ScanCard.Step.Second, + onScanClick = { + createSecondWallet(firstPublicKey = result.data.wallet.publicKey.toHexString()) + }, + ) + } + + innerNavigationState.update { + it.copy(stackSize = 2) + } + } + } + } + } + + private fun createSecondWallet(firstPublicKey: String) { + setLoading(true) + + modelScope.launch { + val secondCardNumber = firstCardTwinNumber.pairNumber().number + val result = tangemSdkManager.createSecondTwinWallet( + firstPublicKey = firstPublicKey, + firstCardId = params.scanResponse.card.cardId, + issuerKeys = getIssuerKeys(), + preparingMessage = Message( + tangemSdkManager.getString(R.string.twins_recreate_title_preparing), + ), + creatingWalletMessage = Message( + tangemSdkManager.getString(R.string.twins_recreate_title_creating_wallet), + ), + initialMessage = Message( + tangemSdkManager.getString( + R.string.twins_recreate_title_format, + secondCardNumber, + ), + ), + ) + + when (result) { + is CompletionResult.Failure -> { + setLoading(false) + showCardVerificationFailedDialog(result.error) + } + is CompletionResult.Success -> { + if (params.mode == Mode.CreateWallet) { + cardRepository.startCardActivation(result.data.cardId) + } + + update { + it.copy( + isLoading = false, + artworkStep = it.artworkStep.next(), + step = OnboardingTwinUM.ScanCard.Step.Third, + onScanClick = { + createThirdWallet( + secondCardPublicKey = result.data.wallet.publicKey, + ) + }, + ) + } + + innerNavigationState.update { + it.copy(stackSize = 3) + } + } + } + } + } + + private fun createThirdWallet(secondCardPublicKey: ByteArray) { + modelScope.launch { + setLoading(true) + + val result = tangemSdkManager.finalizeTwin( + secondCardPublicKey = secondCardPublicKey, + issuerKeyPair = getIssuerKeys(), + cardId = params.scanResponse.card.cardId, + initialMessage = Message( + tangemSdkManager.getString( + R.string.twins_recreate_title_format, + firstCardTwinNumber.number, + ), + ), + ) + + when (result) { + is CompletionResult.Failure -> { + setLoading(false) + showCardVerificationFailedDialog(result.error) + } + is CompletionResult.Success -> { + scanComplete(result.data) + } + } + } + } + + private fun scanComplete(scanResponse: ScanResponse) { + analyticsEventHandler.send(OnboardingEvent.Twins.SetupFinished) + + when (params.mode) { + Mode.WelcomeOnly -> saveWalletAndDone() + Mode.RecreateWallet -> params.modelCallbacks.onDone() + Mode.CreateWallet -> { + modelScope.launch { + setLoading(true) + setTopUpState(scanResponse) + }.saveIn(cryptoCurrencyStatusJobHolder) + } + } + } + + private suspend fun setTopUpState(scanResponse: ScanResponse) = coroutineScope { + val userWallet = userWalletBuilderFactory.create(scanResponse).build() ?: run { + Timber.e("User wallet not created") + setLoading(false) + return@coroutineScope + } + + userWalletsListManager.save(userWallet, canOverride = true) + + cardRepository.finishCardActivation(params.scanResponse.card.cardId) + + fetchCurrencyStatusUseCase.invoke( + userWalletId = userWallet.walletId, + refresh = true, + ).onLeft { + Timber.e("Unable to fetch currency status: $it") + setLoading(false) + } + + val cryptoCurrencyStatus = + getPrimaryCurrencyStatusUpdatesUseCase.invoke(userWallet.walletId).firstOrNull()?.getOrNull() + ?: run { + setLoading(false) + Timber.e("Unable to get currency status") + return@coroutineScope + } + + launch { + getPrimaryCurrencyStatusUpdatesUseCase.invoke(userWallet.walletId) + .collect { + it.onRight { status -> + applyCryptoCurrencyStatusToState(status) + } + } + } + + _uiState.value = OnboardingTwinUM.TopUp( + onBuyCryptoClick = { onBuyCryptoClick(cryptoCurrencyStatus) }, + onRefreshClick = { onRefreshBalanceClick(userWallet) }, + onShowAddressClick = { onShowAddressClick(cryptoCurrencyStatus) }, + isLoading = true, + ) + + innerNavigationState.update { + it.copy(stackSize = 4) + } + } + + private fun applyCryptoCurrencyStatusToState(status: CryptoCurrencyStatus) { + val amount = (status.value as? CryptoCurrencyStatus.Loaded)?.amount ?: return + if (amount > BigDecimal.ZERO) { + params.modelCallbacks.onDone() + } else { + update { + it.copy( + balance = BigDecimal.ZERO.format { crypto(status.currency) }, + onBuyCryptoClick = { onBuyCryptoClick(status) }, + onShowAddressClick = { onShowAddressClick(status) }, + isLoading = false, + ) + } + } + } + + private fun onBuyCryptoClick(status: CryptoCurrencyStatus) { + modelScope.launch { + getLegacyTopUpUrlUseCase(status).onRight { + urlOpener.openUrl(it) + } + } + } + + private fun onShowAddressClick(status: CryptoCurrencyStatus) { + val currency = status.currency + val networkAddress = status.value.networkAddress ?: return + + update { + it.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { + update { + it.copy(bottomSheetConfig = TangemBottomSheetConfig.Empty) + } + }, + content = TokenReceiveBottomSheetConfig( + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = networkAddress, + showMemoDisclaimer = + currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, + onCopyClick = { + Analytics.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(currency.symbol)) + clipboardManager.setText(text = it, isSensitive = true) + }, + onShareClick = { + Analytics.send(TokenReceiveAnalyticsEvent.ButtonShareAddress(currency.symbol)) + shareManager.shareText(text = it) + }, + ), + ), + ) + } + } + + private fun onRefreshBalanceClick(userWallet: UserWallet) { + update { + it.copy(isLoading = true) + } + modelScope.launch { + fetchCurrencyStatusUseCase( + userWalletId = userWallet.walletId, + refresh = true, + ) + } + } + + private fun saveWalletAndDone() { + setLoading(true) + + modelScope.launch { + val userWallet = userWalletBuilderFactory.create(params.scanResponse).build() ?: run { + Timber.e("User wallet not created") + setLoading(false) + return@launch + } + + userWalletsListManager.save(userWallet, canOverride = true) + params.modelCallbacks.onDone() + } + } + + private fun showCardVerificationFailedDialog(error: TangemError) { + if (error !is TangemSdkError.CardVerificationFailed) return + + analyticsEventHandler.send(OnboardingEvent.OfflineAttestationFailed(AnalyticsParam.ScreensSources.Onboarding)) + + val resource = error.localizedDescriptionRes() + val resId = resource.resId ?: R.string.common_unknown_error + val resArgs = resource.args.map { it.value } + + uiMessageSender.send( + cardVerificationFailedDialog( + errorDescription = resourceReference(id = resId, resArgs.toWrappedList()), + onRequestSupport = { + modelScope.launch { + sendFeedbackEmailUseCase(type = FeedbackEmailType.CardAttestationFailed) + } + }, + ), + ) + } + + private suspend fun getIssuerKeys(): KeyPair { + val issuer = issuersConfigStorage.getConfig() + .first { it.publicKey == params.scanResponse.card.issuer.publicKey.toHexString() } + + return KeyPair( + publicKey = issuer.publicKey.hexToBytes(), + privateKey = issuer.privateKey.hexToBytes(), + ) + } + + private inline fun update(updateBlock: (T) -> OnboardingTwinUM) { + _uiState.update { st -> + (st as? T)?.let { updateBlock(it) } ?: st + } + } + + private fun setLoading(flag: Boolean) { + _uiState.update { it.copySealed(isLoading = flag) } + } + + private fun TwinWalletArtworkUM.Leapfrog.Step.next(): TwinWalletArtworkUM.Leapfrog.Step = when (this) { + TwinWalletArtworkUM.Leapfrog.Step.FirstCard -> TwinWalletArtworkUM.Leapfrog.Step.SecondCard + TwinWalletArtworkUM.Leapfrog.Step.SecondCard -> TwinWalletArtworkUM.Leapfrog.Step.FirstCard + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/OnboardingTwin.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/OnboardingTwin.kt new file mode 100644 index 0000000000..c67669148b --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/OnboardingTwin.kt @@ -0,0 +1,327 @@ +package com.tangem.features.onboarding.v2.twin.impl.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.IconToggleButton +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemAnimations +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.twin.impl.ui.state.OnboardingTwinUM + +@Suppress("UnusedPrivateMember", "MagicNumber") +@Composable +internal fun OnboardingTwin(state: OnboardingTwinUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + TwinWalletArtworks( + modifier = Modifier + .padding(top = 64.dp) + .padding(horizontal = 24.dp) + .weight(.48f) + .fillMaxWidth(), + state = state.artwork, + balance = (state as? OnboardingTwinUM.TopUp)?.balance ?: "", + isRefreshing = state.isLoading, + onRefreshBalanceClick = { + (state as? OnboardingTwinUM.TopUp)?.onRefreshClick() + }, + ) + + AnimatedContent( + modifier = Modifier.weight(.52f), + targetState = state, + transitionSpec = TangemAnimations.AnimatedContent + .slide { initial, target -> target.stepIndex > initial.stepIndex }, + contentKey = { it::class }, + ) { st -> + when (st) { + is OnboardingTwinUM.ResetWarning -> ResetWarning(st) + is OnboardingTwinUM.ScanCard -> ScanCard(st) + is OnboardingTwinUM.TopUp -> TopUp(st) + is OnboardingTwinUM.Welcome -> Welcome(st) + OnboardingTwinUM.TopUpPrepare -> {} + } + } + } + + if (state is OnboardingTwinUM.TopUp) { + TokenReceiveBottomSheet(config = state.bottomSheetConfig) + } +} + +@Suppress("LongMethod") +@Composable +private fun ResetWarning(state: OnboardingTwinUM.ResetWarning, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .padding(start = 32.dp, end = 32.dp, bottom = 16.dp) + .weight(1f) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResourceSafe(R.string.common_warning), + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.h2, + ) + + SpacerH16() + + Text( + text = stringResourceSafe(R.string.twins_recreate_warning), + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.body1, + ) + + SpacerH16() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { state.onAcceptClick(!state.acceptToggle) }, + ), + ) { + IconToggleButton(checked = state.acceptToggle, onCheckedChange = state.onAcceptClick) { + AnimatedContent(targetState = state.acceptToggle, label = "Update checked state") { checked -> + Icon( + painter = painterResource( + if (checked) { + R.drawable.ic_accepted_20 + } else { + R.drawable.ic_unticked_20 + }, + ), + contentDescription = null, + tint = if (checked) { + TangemTheme.colors.control.checked + } else { + TangemTheme.colors.icon.secondary + }, + ) + } + } + + Text( + text = stringResourceSafe(R.string.common_understand), + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.body1, + ) + } + } + + PrimaryButton( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.common_continue), + enabled = state.acceptToggle, + onClick = state.onContinueClick, + ) + } +} + +@Composable +private fun TopUp(state: OnboardingTwinUM.TopUp, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .padding(start = 32.dp, end = 32.dp, bottom = 16.dp) + .weight(1f) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResourceSafe(R.string.onboarding_topup_title), + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.h2, + ) + + SpacerH16() + + Text( + text = stringResourceSafe(R.string.onboarding_top_up_body), + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.body1, + ) + } + + PrimaryButton( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.onboarding_top_up_button_but_crypto), + onClick = state.onBuyCryptoClick, + ) + + SecondaryButton( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.onboarding_top_up_button_show_wallet_address), + onClick = state.onShowAddressClick, + ) + } +} + +@Composable +private fun ScanCard(state: OnboardingTwinUM.ScanCard, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .padding(start = 32.dp, end = 32.dp, bottom = 16.dp) + .weight(1f) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResourceSafe(R.string.twins_recreate_title_format, state.cardNumber), + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.h2, + ) + + SpacerH16() + + Text( + text = stringResourceSafe(R.string.onboarding_twins_interrupt_warning), + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.body1, + ) + } + + PrimaryButtonIconEnd( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.onboarding_button_backup_card_format, state.cardNumber), + showProgress = state.isLoading, + iconResId = R.drawable.ic_tangem_24, + onClick = state.onScanClick, + ) + } +} + +@Composable +private fun Welcome(state: OnboardingTwinUM.Welcome, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .padding(start = 32.dp, end = 32.dp, bottom = 16.dp) + .weight(1f) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResourceSafe(R.string.twins_onboarding_subtitle), + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + style = TangemTheme.typography.h2, + ) + + SpacerH16() + + Text( + text = stringResourceSafe(R.string.twins_onboarding_description_format, state.pairCardNumber), + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + style = TangemTheme.typography.body1, + ) + } + + PrimaryButton( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + text = stringResourceSafe(R.string.common_continue), + onClick = state.onContinueClick, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewTopUp() { + TangemThemePreview { + OnboardingTwin(OnboardingTwinUM.TopUp()) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewWelcome() { + TangemThemePreview { + OnboardingTwin(OnboardingTwinUM.Welcome()) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewScanCard() { + TangemThemePreview { + OnboardingTwin(OnboardingTwinUM.ScanCard()) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewResetWarning() { + TangemThemePreview { + var toggle by remember { mutableStateOf(false) } + + OnboardingTwin( + OnboardingTwinUM.ResetWarning( + acceptToggle = toggle, + onAcceptClick = { toggle = it }, + ), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..5fba3214f8 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt @@ -0,0 +1,379 @@ +package com.tangem.features.onboarding.v2.twin.impl.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +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.SpacerH8 +import com.tangem.core.ui.components.SpacerHMax +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.wallets.models.Artwork +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 kotlinx.coroutines.delay +import java.util.concurrent.TimeUnit + +@Immutable +internal sealed class TwinWalletArtworkUM { + data object Spread : TwinWalletArtworkUM() + + data class Leapfrog( + val step: Step, + ) : TwinWalletArtworkUM() { + enum class Step { + FirstCard, SecondCard + } + } + + data object TopUp : TwinWalletArtworkUM() +} + +private data class CardsTransitionState( + val walletCard1: WalletCardTransitionState, + val walletCard2: WalletCardTransitionState, +) + +private data class WalletCardTransitionState( + val alpha: Float = 1f, + val xTranslation: Float = 0f, + val yTranslation: Float = 0f, + val xScale: Float = 1f, + val yScale: Float = 1f, + val rotation: Float = 0f, + val zIndex: Float = 0f, +) + +@Suppress("LongMethod") +@Composable +internal fun TwinWalletArtworks( + state: TwinWalletArtworkUM, + balance: String, + isRefreshing: Boolean, + onRefreshBalanceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier + .heightIn(min = 180.dp) + .widthIn(max = 450.dp), + ) { + val animationWidth = maxWidth + val density = LocalDensity.current.density + + var animationState by remember { + mutableStateOf( + state.toTransitionSetState( + maxWidthDp = animationWidth.value, + density = density, + maxHeightDp = maxHeight.value, + ).first(), + ) + } + + val transition1 = updateTransition(animationState.walletCard1, label = "transition") + val transition2 = updateTransition(animationState.walletCard2, label = "transition") + + LaunchedEffect(state) { + val animationSetState = state.toTransitionSetState( + maxWidthDp = animationWidth.value, + density = density, + maxHeightDp = maxHeight.value, + ) + animationSetState.fastForEach { + animationState = it + val maxTime = maxOf( + transition1.totalDurationNanos, + transition2.totalDurationNanos, + ) + delay(TimeUnit.NANOSECONDS.toMillis(maxTime)) + } + } + + AnimatedVisibility( + visible = state == TwinWalletArtworkUM.TopUp, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .padding(vertical = 24.dp, horizontal = 16.dp) + .fillMaxSize() + .background( + TangemTheme.colors.button.secondary, + shape = TangemTheme.shapes.roundedCornersMedium, + ), + ) + } + + AnimatedTwinCards( + transition1 = transition1, + transition2 = transition2, + modifier = Modifier + .widthIn(max = 450.dp) + .matchParentSize(), + ) + + AnimatedVisibility( + modifier = Modifier.align(Alignment.Center), + visible = state == TwinWalletArtworkUM.TopUp, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SpacerHMax() + Text( + text = stringResourceSafe(R.string.common_balance_title), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + SpacerH8() + Text( + text = balance, + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerHMax() + } + } + + AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), + visible = state == TwinWalletArtworkUM.TopUp, + enter = fadeIn(), + exit = fadeOut(), + ) { + RefreshButton( + isRefreshing = isRefreshing, + onRefreshBalanceClick = onRefreshBalanceClick, + ) + } + } +} + +@Composable +private fun AnimatedTwinCards( + transition1: Transition, + transition2: Transition, + modifier: Modifier = Modifier, +) { + val alpha1 by transition1.animateFloat(label = "alpha1") { it.alpha } + val translationX1 by transition1.animateFloat(label = "translationX1") { it.xTranslation } + val translationY1 by transition1.animateFloat(label = "translationY1") { it.yTranslation } + val rotation1 by transition1.animateFloat(label = "rotation1") { it.rotation } + val scaleX1 by transition1.animateFloat(label = "scaleX1") { it.xScale } + val scaleY1 by transition1.animateFloat(label = "scaleY1") { it.yScale } + val zIndex1 = transition1.currentState.zIndex + + val alpha2 by transition2.animateFloat(label = "alpha2") { it.alpha } + val translationX2 by transition2.animateFloat(label = "translationX2") { it.xTranslation } + val translationY2 by transition2.animateFloat(label = "translationY2") { it.yTranslation } + val rotation2 by transition2.animateFloat(label = "rotation2") { it.rotation } + val scaleX2 by transition2.animateFloat(label = "scaleX2") { it.xScale } + val scaleY2 by transition2.animateFloat(label = "scaleY2") { it.yScale } + val zIndex2 = transition2.currentState.zIndex + + Box(modifier) { + WalletCard( + modifier = Modifier + .zIndex(zIndex2) + .matchParentSize() + .graphicsLayer { + translationX = translationX2 + translationY = translationY2 + } + .graphicsLayer { + alpha = alpha2 + rotationZ = rotation2 + scaleX = scaleX2 + scaleY = scaleY2 + }, + url = Artwork.TWIN_CARD_2_URL, + ) + + WalletCard( + modifier = Modifier + .zIndex(zIndex1) + .matchParentSize() + .graphicsLayer { + scaleX = scaleX1 + scaleY = scaleY1 + } + .graphicsLayer { + alpha = alpha1 + translationX = translationX1 + translationY = translationY1 + rotationZ = rotation1 + }, + url = Artwork.TWIN_CARD_1_URL, + ) + } +} + +@Suppress("MagicNumber", "LongMethod", "UnnecessaryParentheses") +private fun TwinWalletArtworkUM.toTransitionSetState( + maxWidthDp: Float, + maxHeightDp: Float, + density: Float, +): List = when (this) { + TwinWalletArtworkUM.Spread -> { + val scale = 0.8f + listOf( + CardsTransitionState( + walletCard1 = WalletCardTransitionState( + xTranslation = maxWidthDp * -0.15f * density, + yTranslation = -65 * density, + rotation = -5f, + xScale = scale, + yScale = scale, + zIndex = 2f, + ), + walletCard2 = WalletCardTransitionState( + xTranslation = maxWidthDp * 0.15f * density, + yTranslation = 65 * density, + rotation = -5f, + xScale = scale, + yScale = scale, + zIndex = 1f, + ), + ), + ) + } + is TwinWalletArtworkUM.Leapfrog -> { + val translationY = -(maxHeightDp / 2) * density + val secondCardPlace = WalletCardTransitionState().copy( + xTranslation = 0f, + yTranslation = ((maxHeightDp * (1 - 0.83f)) / 4 + 12) * density, + rotation = 0f, + xScale = 0.83f, + yScale = 0.83f, + ) + + if (step == TwinWalletArtworkUM.Leapfrog.Step.FirstCard) { + listOf( + CardsTransitionState( + walletCard1 = WalletCardTransitionState( + yTranslation = translationY, + zIndex = 2f, + ), + walletCard2 = WalletCardTransitionState(), + ), + CardsTransitionState( + walletCard1 = WalletCardTransitionState( + zIndex = 2f, + ), + walletCard2 = secondCardPlace, + ), + ) + } else { + listOf( + CardsTransitionState( + walletCard1 = WalletCardTransitionState(), + walletCard2 = WalletCardTransitionState( + yTranslation = translationY, + zIndex = 2f, + ), + ), + CardsTransitionState( + walletCard1 = secondCardPlace, + walletCard2 = WalletCardTransitionState( + zIndex = 2f, + ), + ), + ) + } + } + TwinWalletArtworkUM.TopUp -> { + val scale = 0.4f + val yTranslation = -maxHeightDp * density - 24 * density + listOf( + CardsTransitionState( + walletCard1 = WalletCardTransitionState( + yTranslation = yTranslation, + xScale = scale, + yScale = scale, + zIndex = 2f, + ), + walletCard2 = WalletCardTransitionState( + yTranslation = yTranslation * 0.35f, + xScale = scale * 0.8f, + yScale = scale * 0.8f, + zIndex = 1f, + ), + ), + ) + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Preview(showBackground = true, widthDp = 360, heightDp = 640, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + Box( + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + var state: TwinWalletArtworkUM by remember { mutableStateOf(TwinWalletArtworkUM.TopUp) } + + TwinWalletArtworks( + state = state, + modifier = Modifier + .padding(top = 250.dp) + .fillMaxWidth(), + balance = "1 USD", + isRefreshing = false, + onRefreshBalanceClick = {}, + ) + + var index by remember { mutableIntStateOf(0) } + + Button( + modifier = Modifier + .zIndex(5f) + .align(Alignment.TopStart), + onClick = { + val list = listOf( + TwinWalletArtworkUM.Spread, + TwinWalletArtworkUM.Leapfrog(step = TwinWalletArtworkUM.Leapfrog.Step.FirstCard), + TwinWalletArtworkUM.Leapfrog(step = TwinWalletArtworkUM.Leapfrog.Step.SecondCard), + TwinWalletArtworkUM.Leapfrog(step = TwinWalletArtworkUM.Leapfrog.Step.FirstCard), + TwinWalletArtworkUM.Leapfrog(step = TwinWalletArtworkUM.Leapfrog.Step.SecondCard), + TwinWalletArtworkUM.TopUp, + ) + + state = list[index % list.size] + index++ + }, + ) { + Text("Animate $state") + } + } + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/state/OnboardingTwinUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/state/OnboardingTwinUM.kt new file mode 100644 index 0000000000..7341af8545 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/state/OnboardingTwinUM.kt @@ -0,0 +1,78 @@ +package com.tangem.features.onboarding.v2.twin.impl.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.features.onboarding.v2.twin.impl.ui.TwinWalletArtworkUM + +@Immutable +internal sealed class OnboardingTwinUM { + + abstract val stepIndex: Int + abstract val isLoading: Boolean + abstract val artwork: TwinWalletArtworkUM + + data object TopUpPrepare : OnboardingTwinUM() { + override val stepIndex: Int = 0 + override val isLoading: Boolean = false + override val artwork: TwinWalletArtworkUM = TwinWalletArtworkUM.Spread + } + + data class Welcome( + override val isLoading: Boolean = false, + val pairCardNumber: Int = 2, + val onContinueClick: () -> Unit = {}, + ) : OnboardingTwinUM() { + override val stepIndex = 0 + override val artwork: TwinWalletArtworkUM = TwinWalletArtworkUM.Spread + } + + data class ResetWarning( + val acceptToggle: Boolean = false, + val onAcceptClick: (Boolean) -> Unit = {}, + val onContinueClick: () -> Unit = {}, + ) : OnboardingTwinUM() { + override val stepIndex: Int = 0 + override val isLoading: Boolean = false + override val artwork: TwinWalletArtworkUM = TwinWalletArtworkUM.Spread + } + + data class ScanCard( + override val isLoading: Boolean = false, + val artworkStep: TwinWalletArtworkUM.Leapfrog.Step = TwinWalletArtworkUM.Leapfrog.Step.FirstCard, + val step: Step = Step.First, + val onScanClick: () -> Unit = {}, + ) : OnboardingTwinUM() { + + enum class Step { + First, Second, Third + } + + val cardNumber: Int = when (artworkStep) { + TwinWalletArtworkUM.Leapfrog.Step.FirstCard -> 1 + TwinWalletArtworkUM.Leapfrog.Step.SecondCard -> 2 + } + + override val stepIndex: Int = 1 + override val artwork: TwinWalletArtworkUM = TwinWalletArtworkUM.Leapfrog(artworkStep) + } + + data class TopUp( + override val isLoading: Boolean = false, + val balance: String = "", + val bottomSheetConfig: TangemBottomSheetConfig = TangemBottomSheetConfig.Empty, + val onBuyCryptoClick: () -> Unit = {}, + val onShowAddressClick: () -> Unit = {}, + val onRefreshClick: () -> Unit = {}, + ) : OnboardingTwinUM() { + override val stepIndex: Int = 2 + override val artwork: TwinWalletArtworkUM = TwinWalletArtworkUM.TopUp + } + + fun copySealed(isLoading: Boolean = this.isLoading): OnboardingTwinUM = when (this) { + is Welcome -> copy(isLoading = isLoading) + is ResetWarning -> copy() + is ScanCard -> copy(isLoading = isLoading) + is TopUp -> copy(isLoading = isLoading) + TopUpPrepare -> this + } +} \ No newline at end of file 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 b36dcef18f..b3cc93357a 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 @@ -103,7 +103,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( config = OnboardingVisaWelcomeComponent.Config.Welcome, params = OnboardingVisaWelcomeComponent.Params( childParams = childParams, - onDone = { model.stackNavigation.push(OnboardingVisaRoute.AccessCode) }, + onDone = { model.stackNavigation.pushNew(OnboardingVisaRoute.AccessCode) }, ), ) is OnboardingVisaRoute.WelcomeBack -> OnboardingVisaWelcomeComponent( @@ -146,6 +146,12 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( appComponentContext = factoryContext, config = OnboardingVisaInProgressComponent.Config( scanResponse = model.currentScanResponse.value, + type = when (route.from) { + OnboardingVisaRoute.InProgress.From.Approve -> + OnboardingVisaInProgressComponent.Config.Type.AfterApprove + OnboardingVisaRoute.InProgress.From.PinCode -> + OnboardingVisaInProgressComponent.Config.Type.AfterPinCode + }, ), params = OnboardingVisaInProgressComponent.Params( childParams = childParams, @@ -161,7 +167,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( params = OnboardingVisaOtherWalletComponent.Params( childParams = childParams, onDone = { - model.stackNavigation.push(OnboardingVisaRoute.PinCode(activationOrderInfo = it)) + model.stackNavigation.pushNew(OnboardingVisaRoute.PinCode(activationOrderInfo = it)) }, ), ) @@ -174,7 +180,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( params = OnboardingVisaPinCodeComponent.Params( childParams = childParams, onDone = { - model.stackNavigation.push( + model.stackNavigation.pushNew( OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode), ) }, @@ -190,7 +196,7 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( params = OnboardingVisaApproveComponent.Params( childParams = childParams, onDone = { - model.stackNavigation.push( + model.stackNavigation.pushNew( OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.Approve), ) }, 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 086d02041d..71c5adec28 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 @@ -7,6 +7,11 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.error.ext.universalError +import com.tangem.core.ui.utils.showErrorDialog +import com.tangem.domain.visa.error.VisaAPIError +import com.tangem.domain.visa.error.VisaAuthorizationAPIError import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest @@ -34,6 +39,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( @Suppress("UnusedPrivateMember") private val tangemSdkManager: TangemSdkManager, private val visaAuthRepository: VisaAuthRepository, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params: OnboardingVisaAccessCodeComponent.Config = paramsContainer.require() @@ -138,7 +144,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( ) }.getOrElse { loading(false) - // TODO show alert + uiMessageSender.showErrorDialog(VisaAuthorizationAPIError) return@launch } @@ -148,17 +154,22 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( authorizationChallenge = challengeToSign, ), activationInput = activationStatus.activationInput, - ) as? CompletionResult.Success ?: run { - loading(false) - // TODO show alert - return@launch + ) + + val resultData = when (result) { + is CompletionResult.Failure -> { + loading(false) + uiMessageSender.showErrorDialog(result.error.universalError) + return@launch + } + is CompletionResult.Success -> result.data } runCatching { - visaActivationRepository.activateCard(result.data.signedActivationData) + visaActivationRepository.activateCard(resultData.signedActivationData) }.onFailure { loading(false) - // TODO show alert + uiMessageSender.showErrorDialog(VisaAPIError) return@launch } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/ui/OnboardingVisaAccessCode.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/ui/OnboardingVisaAccessCode.kt index 1557683bfe..090ce03d84 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/ui/OnboardingVisaAccessCode.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/ui/OnboardingVisaAccessCode.kt @@ -23,6 +23,7 @@ import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH16 import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemAnimations import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -43,7 +44,7 @@ internal fun OnboardingVisaAccessCode(state: OnboardingVisaAccessCodeUM, modifie .weight(1f), targetState = state.step, transitionSpec = TangemAnimations.AnimatedContent - .slide { initial, target -> initial.ordinal > target.ordinal }, + .slide { initial, target -> target.ordinal > initial.ordinal }, ) { step -> Content( state = state, @@ -87,9 +88,9 @@ private fun Content( modifier = Modifier .padding(horizontal = 32.dp), text = if (reEnterAccessCodeState) { - "Re-enter your access code" // TODO + stringResourceSafe(R.string.onboarding_access_code_repeat_code_title) } else { - "Create access code" // TODO + stringResourceSafe(R.string.onboarding_access_code_intro_title) }, style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, @@ -98,11 +99,10 @@ private fun Content( SpacerH16() - // TODO Text( modifier = Modifier .padding(horizontal = 32.dp), - text = "The access code will be used manage your payment account and protect it from unauthorized access", + text = stringResourceSafe(R.string.visa_onboarding_access_code_description), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, 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 396a8039b2..a4803e73d0 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 @@ -6,6 +6,10 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.error.ext.universalError +import com.tangem.core.ui.utils.showErrorDialog +import com.tangem.domain.visa.error.VisaAPIError import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.repository.VisaActivationRepository @@ -27,6 +31,7 @@ internal class OnboardingVisaApproveModel @Inject constructor( visaActivationRepositoryFactory: VisaActivationRepository.Factory, override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() @@ -56,7 +61,7 @@ internal class OnboardingVisaApproveModel @Inject constructor( visaActivationRepository.getCustomerWalletAcceptanceData(params.preparationDataForApprove.request) }.getOrElse { loading(false) - // TODO show dialog + uiMessageSender.showErrorDialog(VisaAPIError) return@launch } @@ -66,17 +71,22 @@ internal class OnboardingVisaApproveModel @Inject constructor( targetAddress = params.preparationDataForApprove.customerWalletAddress, dataToSign = dataToSign, ), - ) as? CompletionResult.Success ?: run { - loading(false) - // TODO show dialog - return@launch + ) + + val resultData = when (result) { + is CompletionResult.Failure -> { + loading(false) + uiMessageSender.showErrorDialog(result.error.universalError) + return@launch + } + is CompletionResult.Success -> result.data } runCatching { - visaActivationRepository.approveByCustomerWallet(result.data) + visaActivationRepository.approveByCustomerWallet(resultData) }.onFailure { loading(false) - // TODO show dialog + uiMessageSender.showErrorDialog(VisaAPIError) return@launch } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/ui/OnboardingVisaApprove.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/ui/OnboardingVisaApprove.kt index f93ebd7cd3..106973d0da 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/ui/OnboardingVisaApprove.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/ui/OnboardingVisaApprove.kt @@ -14,7 +14,8 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.impl.R @@ -43,7 +44,7 @@ internal fun OnboardingVisaApprove(state: OnboardingVisaApproveUM, modifier: Mod SpacerH(86.dp) Text( - text = "Prepare Tangem Wallet", + text = stringResourceSafe(R.string.visa_onboarding_tangem_approve_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, @@ -52,7 +53,7 @@ internal fun OnboardingVisaApprove(state: OnboardingVisaApproveUM, modifier: Mod SpacerH(12.dp) Text( - text = "Prepare the Tangem card\nand tap to approve", + text = stringResourceSafe(R.string.visa_onboarding_tangem_approve_description), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, @@ -65,7 +66,7 @@ internal fun OnboardingVisaApprove(state: OnboardingVisaApproveUM, modifier: Mod .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .fillMaxWidth(), primaryButton = NavigationButton( - textReference = TextReference.Str("Approve"), + textReference = resourceReference(R.string.common_approve), iconRes = R.drawable.ic_tangem_24, isIconVisible = true, showProgress = state.approveButtonLoading, 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 381cfc11aa..3d48e57718 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 @@ -3,7 +3,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.model import androidx.compose.runtime.Stable import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference 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 @@ -32,7 +32,7 @@ internal class OnboardingVisaChooseWalletModel @Inject constructor( val selectedOption = SelectableChainRowUM( event = Event.TangemWallet, icon = R.drawable.ic_tangem_24, - text = TextReference.Str("Tangem Wallet"), + text = resourceReference(R.string.common_tangem_wallet), ) return OnboardingVisaChooseWalletUM( @@ -41,7 +41,7 @@ internal class OnboardingVisaChooseWalletModel @Inject constructor( SelectableChainRowUM( event = Event.OtherWallet, icon = R.drawable.ic_wallet_filled_24, - text = TextReference.Str("Other Wallet"), + text = resourceReference(R.string.visa_onboarding_other_wallet), ), ), selectedOption = selectedOption, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/ui/OnboardingVisaChooseWallet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/ui/OnboardingVisaChooseWallet.kt index b44e28c2c6..5cd9a11f10 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/ui/OnboardingVisaChooseWallet.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/ui/OnboardingVisaChooseWallet.kt @@ -21,6 +21,8 @@ import com.tangem.core.ui.components.rows.RowContentContainer import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.outsetBorder import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.impl.R @@ -45,10 +47,7 @@ internal fun OnboardingVisaChooseWallet(state: OnboardingVisaChooseWalletUM, mod ) { Notification( config = NotificationConfig( - subtitle = TextReference.Str( - "Please choose the wallet you started the registration" + - " process with to sign the transaction for creating your account on the Blockchain.", - ), + subtitle = resourceReference(R.string.visa_onboarding_approve_wallet_selector_notification_message), iconResId = R.drawable.ic_alert_circle_24, ), iconTint = TangemTheme.colors.icon.accent, @@ -62,7 +61,7 @@ internal fun OnboardingVisaChooseWallet(state: OnboardingVisaChooseWalletUM, mod start = 12.dp, end = 12.dp, ), - text = "Choose wallet", + text = stringResourceSafe(R.string.visa_onboarding_wallet_list_header), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, ) @@ -79,7 +78,7 @@ internal fun OnboardingVisaChooseWallet(state: OnboardingVisaChooseWalletUM, mod .imePadding() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .fillMaxWidth(), - text = "Continue", + text = stringResourceSafe(R.string.common_continue), onClick = state.onContinueClick, ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/OnboardingVisaInProgressComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/OnboardingVisaInProgressComponent.kt index 6aaf63c0bc..2729fc42db 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/OnboardingVisaInProgressComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/OnboardingVisaInProgressComponent.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.onEach internal class OnboardingVisaInProgressComponent( appComponentContext: AppComponentContext, - config: Config, + private val config: Config, private val params: Params, ) : ComposableContentComponent, AppComponentContext by appComponentContext { @@ -35,12 +35,18 @@ internal class OnboardingVisaInProgressComponent( OnboardingVisaInProgress( modifier = modifier, + type = config.type, ) } data class Config( val scanResponse: ScanResponse, - ) + val type: Type, + ) { + enum class Type { + AfterApprove, AfterPinCode + } + } data class Params( val childParams: DefaultOnboardingVisaComponent.ChildParams, 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 3d79bce2c1..b8d608e1d3 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 @@ -5,16 +5,19 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.utils.showErrorDialog import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.datasource.local.visa.VisaOTPStorage import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.visa.error.VisaActivationError +import com.tangem.domain.visa.error.VisaAuthorizationAPIError import com.tangem.domain.visa.model.* import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.domain.visa.repository.VisaAuthRepository import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Config import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent.Params import com.tangem.features.onboarding.v2.visa.impl.route.OnboardingVisaRoute @@ -35,8 +38,9 @@ internal class OnboardingVisaInProgressModel @Inject constructor( private val visaAuthRepository: VisaAuthRepository, private val visaAuthTokenStorage: VisaAuthTokenStorage, private val otpStorage: VisaOTPStorage, - private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, private val userWalletsListManager: UserWalletsListManager, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() @@ -59,7 +63,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( is VisaActivationRemoteState.CardWalletSignatureRequired, VisaActivationRemoteState.BlockedForActivation, -> { - // TODO show alert inconsistent state + uiMessageSender.showErrorDialog(VisaActivationError.InconsistentRemoteState) return@launch } is VisaActivationRemoteState.CustomerWalletSignatureRequired, @@ -93,7 +97,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( val newTokens = runCatching { visaAuthRepository.refreshAccessTokens(authTokens.refreshToken) }.getOrElse { - // TODO show alert + uiMessageSender.showErrorDialog(VisaAuthorizationAPIError) return } @@ -110,9 +114,8 @@ internal class OnboardingVisaInProgressModel @Inject constructor( val newActivationStatus = VisaCardActivationStatus.Activated(visaAuthTokens = authTokens) requireNotNull( - value = UserWalletBuilder( - scanResponse.copy(visaCardActivationStatus = newActivationStatus), - generateWalletNameUseCase, + value = userWalletBuilderFactory.create( + scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus), ).build(), lazyMessage = { "User wallet not created" }, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/ui/OnboardingVisaInProgress.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/ui/OnboardingVisaInProgress.kt index d30e9a0b79..6a65f28e38 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/ui/OnboardingVisaInProgress.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/ui/OnboardingVisaInProgress.kt @@ -1,29 +1,77 @@ package com.tangem.features.onboarding.v2.visa.impl.child.inprogress.ui +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerH12 +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.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.visa.impl.child.inprogress.OnboardingVisaInProgressComponent @Composable -internal fun OnboardingVisaInProgress(modifier: Modifier = Modifier) { - Column( - modifier +internal fun OnboardingVisaInProgress( + type: OnboardingVisaInProgressComponent.Config.Type, + modifier: Modifier = Modifier, +) { + val composition by rememberLottieComposition(spec = LottieCompositionSpec.RawRes(R.raw.anim_visa_loading)) + val progress by animateLottieCompositionAsState( + composition, + iterations = LottieConstants.IterateForever, + ) + + Box( + modifier = modifier .fillMaxSize() .navigationBarsPadding(), + contentAlignment = Alignment.Center, ) { Column( - Modifier - .weight(1f) - .padding(top = 12.dp) - .padding(horizontal = 16.dp), + Modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - // TODO + LottieAnimation( + modifier = Modifier.size(64.dp), + composition = composition, + progress = { progress }, + ) + SpacerH(26.dp) + Text( + text = stringResourceSafe(R.string.visa_onboarding_in_progress_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH12() + Text( + text = when (type) { + OnboardingVisaInProgressComponent.Config.Type.AfterApprove -> + stringResourceSafe(R.string.visa_onboarding_in_progress_description) + OnboardingVisaInProgressComponent.Config.Type.AfterPinCode -> + stringResourceSafe(R.string.visa_onboarding_in_progress_issuer_description) + }, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) } } } @@ -32,6 +80,8 @@ internal fun OnboardingVisaInProgress(modifier: Modifier = Modifier) { @Composable private fun Preview() { TangemThemePreview { - OnboardingVisaInProgress() + OnboardingVisaInProgress( + type = OnboardingVisaInProgressComponent.Config.Type.AfterPinCode, + ) } } \ No newline at end of file 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 53c043fbea..875e5a4b49 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 @@ -68,10 +68,10 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( } private fun onShareClicked() { - shareManager.shareText("https://tangem.com/") + shareManager.shareText("https://tangem.com/") // TODO } private fun onOpenInBrowserClicked() { - urlOpener.openUrl("https://tangem.com/") + 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/otherwallet/ui/OnboardingVisaOtherWallet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/ui/OnboardingVisaOtherWallet.kt index 4b0a8c2f3f..68b39dbc61 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/ui/OnboardingVisaOtherWallet.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/ui/OnboardingVisaOtherWallet.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.SpacerH12 +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.onboarding.v2.impl.R @@ -44,7 +45,7 @@ internal fun OnboardingVisaOtherWallet(state: OnboardingVisaOtherWalletUM, modif SpacerH12() Text( - text = "Go to Website", + text = stringResourceSafe(R.string.visa_onboarding_wallet_connect_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, @@ -53,9 +54,7 @@ internal fun OnboardingVisaOtherWallet(state: OnboardingVisaOtherWalletUM, modif SpacerH12() Text( - text = "You will be able to complete your connection \n" + - "on the third-party web-site \n" + - "and back to the Tangem app", + text = stringResourceSafe(R.string.visa_onboarding_wallet_connect_description), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, @@ -68,7 +67,7 @@ internal fun OnboardingVisaOtherWallet(state: OnboardingVisaOtherWalletUM, modif modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), - text = "Open in Browser", + text = stringResourceSafe(R.string.common_open_in_browser), onClick = state.onOpenInBrowserClick, ) @@ -78,7 +77,7 @@ internal fun OnboardingVisaOtherWallet(state: OnboardingVisaOtherWalletUM, modif modifier = Modifier .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .fillMaxWidth(), - text = "Share Link", + text = stringResourceSafe(R.string.common_share_link), onClick = state.onOpenInBrowserClick, ) } 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 0b02574d50..bcb96ade2f 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 @@ -5,6 +5,7 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.resourceReference import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.visa.SetVisaPinCodeUseCase @@ -13,7 +14,7 @@ import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.wallets.builder.UserWalletBuilder import com.tangem.domain.wallets.legacy.UserWalletsListManager import com.tangem.domain.wallets.models.UserWallet -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase +import com.tangem.features.onboarding.v2.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.utils.coroutines.CoroutineDispatcherProvider @@ -31,7 +32,7 @@ import javax.inject.Inject internal class OnboardingVisaPinCodeModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, private val userWalletsListManager: UserWalletsListManager, private val authTokenStorage: VisaAuthTokenStorage, private val otpStorage: VisaAuthTokenStorage, @@ -57,24 +58,32 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( } private fun onPinCodeChange(pin: String) { - if (pin.all { it.isDigit() }) { + if (PinCodeValidation.validateAllDigits(pin)) { + val isError = PinCodeValidation.validateLength(pin) && PinCodeValidation.validate(pin).not() + _uiState.update { it.copy( pinCode = pin, - submitButtonEnabled = checkPinCode(pin), + submitButtonEnabled = PinCodeValidation.validate(pin), + error = if (isError) { + resourceReference(R.string.visa_onboarding_pin_validation_error_message) + } else { + null + }, ) } } } private fun onSubmitClick() { - if (checkPinCode(_uiState.value.pinCode).not()) return + val pinCode = _uiState.value.pinCode + if (PinCodeValidation.validate(pinCode).not()) return modelScope.launch { loading(true) setVisaPinCodeUseCase( - pinCode = _uiState.value.pinCode, + pinCode = pinCode, visaCardId = visaCardId, activationOrderId = params.activationOrderInfo.orderId, ).onLeft { @@ -88,10 +97,6 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( } } - private fun checkPinCode(pin: String): Boolean { - return pin.length == PIN_CODE_LENGTH - } - private fun loading(state: Boolean) { _uiState.update { it.copy(submitButtonLoading = state) } } @@ -111,15 +116,10 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( ) requireNotNull( - value = UserWalletBuilder( - scanResponse.copy(visaCardActivationStatus = newActivationStatus), - generateWalletNameUseCase, + value = userWalletBuilderFactory.create( + scanResponse = scanResponse.copy(visaCardActivationStatus = newActivationStatus), ).build(), lazyMessage = { "User wallet not created" }, ) } - - private companion object { - const val PIN_CODE_LENGTH = 4 - } } \ 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/model/PinCodeValidation.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/PinCodeValidation.kt new file mode 100644 index 0000000000..99c721134d --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/PinCodeValidation.kt @@ -0,0 +1,31 @@ +package com.tangem.features.onboarding.v2.visa.impl.child.pincode.model + +internal object PinCodeValidation { + + private const val PIN_CODE_LENGTH = 4 + + fun validate(pinCode: String): Boolean { + return sequenceOf( + ::validateAllDigits, + ::validateLength, + ::validateNoRepeatedDigits, + ::validateNoConsecutiveDigits, + ).all { it(pinCode) } + } + + fun validateAllDigits(pinCode: String): Boolean { + return pinCode.all { it.isDigit() } + } + + fun validateLength(pinCode: String): Boolean { + return pinCode.length == PIN_CODE_LENGTH + } + + private fun validateNoRepeatedDigits(pinCode: String): Boolean { + return pinCode.toSet().size == PIN_CODE_LENGTH + } + + private fun validateNoConsecutiveDigits(pinCode: String): Boolean { + return pinCode.windowed(2).all { it[0] + 1 == it[1] }.not() + } +} \ 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/ui/OnboardingVisaPinCode.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/ui/OnboardingVisaPinCode.kt index c65e7afaeb..cb2d5993c8 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/ui/OnboardingVisaPinCode.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/ui/OnboardingVisaPinCode.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -31,9 +32,14 @@ import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH16 -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.components.SpacerH4 +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.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state.OnboardingVisaPinCodeUM import kotlinx.coroutines.delay @@ -53,7 +59,7 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "Create\nPIN Code", + text = stringResourceSafe(R.string.visa_onboarding_pin_code_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, @@ -62,7 +68,7 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod SpacerH16() Text( - text = "Set up a 4-digit code.\nIt will be used for payments.", + text = stringResourceSafe(R.string.visa_onboarding_pin_code_description), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, @@ -79,7 +85,7 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .fillMaxWidth(), primaryButton = NavigationButton( - textReference = TextReference.Str("Submit"), + textReference = resourceReference(R.string.common_submit), onClick = state.onSubmitClick, showProgress = state.submitButtonLoading, isEnabled = state.submitButtonEnabled, @@ -91,13 +97,29 @@ internal fun OnboardingVisaPinCode(state: OnboardingVisaPinCodeUM, modifier: Mod @Composable private fun PinCodeSection(state: OnboardingVisaPinCodeUM, modifier: Modifier = Modifier) { val focusRequester = remember { FocusRequester() } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PinCode( + modifier = modifier, + value = state.pinCode, + onValueChange = state.onPinCodeChange, + focusRequester = focusRequester, + ) - PinCode( - modifier = modifier, - value = state.pinCode, - onValueChange = state.onPinCodeChange, - focusRequester = focusRequester, - ) + AnimatedVisibility( + visible = state.error != null, + ) { + val error = remember(this) { requireNotNull(state.error) } + Column { + SpacerH4() + Text( + text = error.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.warning, + textAlign = TextAlign.Center, + ) + } + } + } LaunchedEffect(Unit) { delay(timeMillis = 300) @@ -134,9 +156,11 @@ private fun PinCode( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done, ), - keyboardActions = KeyboardActions(onDone = { - keyboardController?.hide() - },), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + }, + ), modifier = Modifier .alpha(alpha = 0.01f) .focusRequester(focusRequester), @@ -194,6 +218,7 @@ private fun Preview() { OnboardingVisaPinCode( state = OnboardingVisaPinCodeUM( pinCode = "1234", + error = stringReference("PIN Code error"), ), ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/ui/state/OnboardingVisaPinCodeUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/ui/state/OnboardingVisaPinCodeUM.kt index 9b17f93c5a..adc4df53d1 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/ui/state/OnboardingVisaPinCodeUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/ui/state/OnboardingVisaPinCodeUM.kt @@ -1,7 +1,10 @@ package com.tangem.features.onboarding.v2.visa.impl.child.pincode.ui.state +import com.tangem.core.ui.extensions.TextReference + internal data class OnboardingVisaPinCodeUM( val pinCode: String = "", + val error: TextReference? = null, val onPinCodeChange: (String) -> Unit = {}, val submitButtonLoading: Boolean = false, val submitButtonEnabled: Boolean = true, 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 82a7092b99..db3f28bd1a 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 @@ -5,6 +5,10 @@ import com.tangem.common.extensions.toHexString import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.error.ext.universalError +import com.tangem.core.ui.utils.showErrorDialog +import com.tangem.domain.visa.error.VisaAPIError import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.model.VisaCustomerWalletDataToSignRequest import com.tangem.domain.visa.repository.VisaActivationRepository @@ -28,6 +32,7 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, + private val uiMessageSender: UiMessageSender, ) : Model() { private val params = paramsContainer.require() @@ -68,24 +73,29 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( visaActivationRepository.getCardWalletAcceptanceData(params.dataToSignRequest) }.getOrElse { loading(false) - // TODO show alert + uiMessageSender.showErrorDialog(VisaAPIError) return@launch } val result = tangemSdkManager.activateVisaCard( mode = VisaCardActivationTaskMode.SignOnly(dataToSignByCardWallet = dataToSignByCardWallet), activationInput = params.activationInput, - ) as? CompletionResult.Success ?: run { - loading(false) - // TODO show alert - return@launch + ) + + val resultData = when (result) { + is CompletionResult.Failure -> { + loading(false) + uiMessageSender.showErrorDialog(result.error.universalError) + return@launch + } + is CompletionResult.Success -> result.data } runCatching { - visaActivationRepository.activateCard(result.data.signedActivationData) + visaActivationRepository.activateCard(resultData.signedActivationData) }.onFailure { loading(false) - // TODO show alert + uiMessageSender.showErrorDialog(VisaAPIError) return@launch } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/ui/OnboardingVisaWelcome.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/ui/OnboardingVisaWelcome.kt index 0a209235b0..9a01f706d6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/ui/OnboardingVisaWelcome.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/ui/OnboardingVisaWelcome.kt @@ -16,6 +16,7 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.PrimaryButtonIconEnd import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH12 +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.onboarding.v2.impl.R @@ -47,8 +48,10 @@ internal fun OnboardingVisaWelcome(state: OnboardingVisaWelcomeUM, modifier: Mod Text( text = when (state.mode) { - OnboardingVisaWelcomeUM.Mode.Hello -> "Hello, ${state.userName}!" - OnboardingVisaWelcomeUM.Mode.WelcomeBack -> "Welcome back, ${state.userName}!" + OnboardingVisaWelcomeUM.Mode.Hello -> + stringResourceSafe(R.string.visa_onboarding_welcome_title) + OnboardingVisaWelcomeUM.Mode.WelcomeBack -> + stringResourceSafe(R.string.visa_onboarding_welcome_back_title) }, style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, @@ -58,8 +61,10 @@ internal fun OnboardingVisaWelcome(state: OnboardingVisaWelcomeUM, modifier: Mod Text( text = when (state.mode) { - OnboardingVisaWelcomeUM.Mode.Hello -> "bla bla bla //TODO" - OnboardingVisaWelcomeUM.Mode.WelcomeBack -> "bla bla bla? //TODO" + OnboardingVisaWelcomeUM.Mode.Hello -> + stringResourceSafe(R.string.visa_onboarding_welcome_description) + OnboardingVisaWelcomeUM.Mode.WelcomeBack -> + stringResourceSafe(R.string.visa_onboarding_welcome_back_description) }, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, @@ -72,7 +77,7 @@ internal fun OnboardingVisaWelcome(state: OnboardingVisaWelcomeUM, modifier: Mod modifier = Modifier .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .fillMaxWidth(), - text = "Start Activation", + text = stringResourceSafe(R.string.visa_onboarding_welcome_button_title), onClick = state.onContinueClick, ) } @@ -81,7 +86,7 @@ internal fun OnboardingVisaWelcome(state: OnboardingVisaWelcomeUM, modifier: Mod modifier = Modifier .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .fillMaxWidth(), - text = "Continue Activation", + text = stringResourceSafe(R.string.visa_onboarding_welcome_back_button_title), showProgress = state.continueButtonLoading, iconResId = R.drawable.ic_tangem_24, onClick = state.onContinueClick, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/ui/state/OnboardingVisaWelcomeUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/ui/state/OnboardingVisaWelcomeUM.kt index d1f975f9b3..069b63d30f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/ui/state/OnboardingVisaWelcomeUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/ui/state/OnboardingVisaWelcomeUM.kt @@ -2,7 +2,6 @@ package com.tangem.features.onboarding.v2.visa.impl.child.welcome.ui.state internal data class OnboardingVisaWelcomeUM( val mode: Mode = Mode.Hello, - val userName: String = "", val continueButtonLoading: Boolean = false, val onContinueClick: () -> Unit = {}, ) { 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 5e98a81f2b..f93540c8ee 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 @@ -4,7 +4,6 @@ import androidx.compose.runtime.Stable import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop -import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.router.stack.pushNew import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -69,7 +68,7 @@ internal class OnboardingVisaModel @Inject constructor( customerWalletAddress = result.customerWalletTargetAddress, request = result.customerWalletDataToSignRequest, ) - stackNavigation.push( + stackNavigation.pushNew( if (foundWalletCardId != null) { OnboardingVisaRoute.TangemWalletApproveOption( foundWalletCardId = foundWalletCardId, @@ -100,7 +99,7 @@ internal class OnboardingVisaModel @Inject constructor( route: OnboardingVisaRoute.ChooseWallet, event: OnboardingVisaChooseWalletComponent.Params.Event, ) { - stackNavigation.push( + stackNavigation.pushNew( when (event) { OnboardingVisaChooseWalletComponent.Params.Event.TangemWallet -> OnboardingVisaRoute.TangemWalletApproveOption( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/Titles.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/Titles.kt index 5da3a52e3d..87aaac10b3 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/Titles.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/Titles.kt @@ -1,16 +1,22 @@ package com.tangem.features.onboarding.v2.visa.impl.route import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.onboarding.v2.impl.R -// TODO add translations [REDACTED_TASK_KEY] internal fun OnboardingVisaRoute.screenTitle(): TextReference = when (this) { - OnboardingVisaRoute.AccessCode -> TextReference.Str("Access code") - is OnboardingVisaRoute.ChooseWallet -> TextReference.Str("Account activation") - is OnboardingVisaRoute.InProgress -> TextReference.Str("In progress") - is OnboardingVisaRoute.OtherWalletApproveOption -> TextReference.Str("Wallet connection") - is OnboardingVisaRoute.PinCode -> TextReference.Str("PIN code") - is OnboardingVisaRoute.TangemWalletApproveOption -> TextReference.Str("Wallet connection") + OnboardingVisaRoute.AccessCode -> + resourceReference(R.string.visa_onboarding_access_code_navigation_title) + is OnboardingVisaRoute.ChooseWallet -> + resourceReference(R.string.visa_onboarding_account_activation_navigation_title) + is OnboardingVisaRoute.InProgress -> + resourceReference(R.string.common_in_progress) + is OnboardingVisaRoute.OtherWalletApproveOption, + is OnboardingVisaRoute.TangemWalletApproveOption, + -> resourceReference(R.string.visa_onboarding_wallet_connection_navigation_title) + is OnboardingVisaRoute.PinCode -> + resourceReference(R.string.visa_onboarding_pin_code_navigation_title) is OnboardingVisaRoute.Welcome, is OnboardingVisaRoute.WelcomeBack, - -> TextReference.Str("Getting started") + -> resourceReference(R.string.onboarding_getting_started) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/ui/OnboardingVisa.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/ui/OnboardingVisa.kt deleted file mode 100644 index 40a9a8597d..0000000000 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/ui/OnboardingVisa.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.features.onboarding.v2.visa.impl.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -internal fun OnboardingVisa(modifier: Modifier = Modifier, childContent: @Composable (Modifier) -> Unit) { - childContent(modifier) -} \ No newline at end of file diff --git a/features/onboarding/src/main/java/com/tangem/feature/onboarding/presentation/wallet2/ui/PassphraseInfoBottomSheet.kt b/features/onboarding/src/main/java/com/tangem/feature/onboarding/presentation/wallet2/ui/PassphraseInfoBottomSheet.kt index cddeb6277c..a2912a2666 100644 --- a/features/onboarding/src/main/java/com/tangem/feature/onboarding/presentation/wallet2/ui/PassphraseInfoBottomSheet.kt +++ b/features/onboarding/src/main/java/com/tangem/feature/onboarding/presentation/wallet2/ui/PassphraseInfoBottomSheet.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/OnrampFeatureToggles.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/OnrampFeatureToggles.kt index 9202fd9fc8..7d46cb6ad1 100644 --- a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/OnrampFeatureToggles.kt +++ b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/OnrampFeatureToggles.kt @@ -3,6 +3,4 @@ package com.tangem.features.onramp interface OnrampFeatureToggles { val isFeatureEnabled: Boolean - - val isHotTokensEnabled: Boolean } \ No newline at end of file diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 2df9a81756..b32858e319 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.core.decompose) implementation(projects.core.navigation) implementation(projects.core.ui) + implementation(projects.core.utils) /** Project - Common */ implementation(projects.common.routing) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/DefaultOnrampFeatureToggles.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/DefaultOnrampFeatureToggles.kt index 5a0c6f5a15..db7f335efe 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/DefaultOnrampFeatureToggles.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/DefaultOnrampFeatureToggles.kt @@ -8,7 +8,4 @@ internal class DefaultOnrampFeatureToggles( override val isFeatureEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(name = "ONRAMP_ENABLED") - - override val isHotTokensEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "ONRAMP_HOT_TOKENS_ENABLED") } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ui/ConfirmResidencyBottomSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ui/ConfirmResidencyBottomSheet.kt index 4fedef0994..55250b545a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ui/ConfirmResidencyBottomSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/confirmresidency/ui/ConfirmResidencyBottomSheet.kt @@ -17,7 +17,7 @@ import coil.request.ImageRequest import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.extensions.resolveReference diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt index ed8f3c543a..97488cd589 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddToPortfolioModel.kt @@ -71,7 +71,7 @@ internal class OnrampAddToPortfolioModel @Inject constructor( addCryptoCurrenciesUseCase( userWalletId = params.userWalletId, - currencies = listOfNotNull(params.cryptoCurrency), + currency = params.cryptoCurrency, ) .onRight { params.onSuccessAdding(params.cryptoCurrency.id) } .onLeft { changeAddButtonProgressStatus(isProgress = false) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioBottomSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioBottomSheet.kt index 253dd52eed..c3e18ec36c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioBottomSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/ui/OnrampAddToPortfolioBottomSheet.kt @@ -1,7 +1,7 @@ package com.tangem.features.onramp.hottokens.portfolio.ui import androidx.compose.runtime.Composable -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.res.TangemTheme diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt index 63a9b5ce7e..3b86caa30d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/main/model/OnrampMainComponentModel.kt @@ -10,6 +10,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.domain.demo.IsDemoCardUseCase @@ -25,7 +26,6 @@ import com.tangem.features.onramp.main.entity.* import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory import com.tangem.features.onramp.main.entity.factory.amount.OnrampAmountStateFactory import com.tangem.features.onramp.providers.entity.SelectProviderResult -import com.tangem.features.onramp.utils.InputManager import com.tangem.features.onramp.utils.sendOnrampErrorEvent import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/SelectPaymentMethodBottomSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/SelectPaymentMethodBottomSheet.kt index 27096e2eb8..e58782f5a5 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/SelectPaymentMethodBottomSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/paymentmethod/ui/SelectPaymentMethodBottomSheet.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/ui/SelectProviderBottomSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/ui/SelectProviderBottomSheet.kt index 39cc85083b..6f74ef7c95 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/ui/SelectProviderBottomSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/providers/ui/SelectProviderBottomSheet.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.util.fastForEach import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.extensions.TextReference diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt index e7a8f8dedf..8d0835efff 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/model/OnrampSelectCountryModel.kt @@ -4,6 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.onramp.FetchOnrampCountriesUseCase @@ -20,7 +21,6 @@ import com.tangem.features.onramp.selectcountry.entity.CountryListUMController import com.tangem.features.onramp.selectcountry.entity.transformer.UpdateCountryItemsErrorTransformer import com.tangem.features.onramp.selectcountry.entity.transformer.UpdateCountryItemsLoadingTransformer import com.tangem.features.onramp.selectcountry.entity.transformer.UpdateCountryItemsTransformer -import com.tangem.features.onramp.utils.InputManager import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer import com.tangem.features.onramp.utils.sendOnrampErrorEvent diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/ui/SelectCountryBottomSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/ui/SelectCountryBottomSheet.kt index 224e1c4168..aaea4ccb7e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/ui/SelectCountryBottomSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcountry/ui/SelectCountryBottomSheet.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.text.style.TextOverflow import coil.compose.AsyncImage import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.TextShimmer -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.buttons.SecondarySmallButton diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt index 93d64b7bca..1162910ed0 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/model/OnrampSelectCurrencyModel.kt @@ -4,6 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.onramp.FetchOnrampCurrenciesUseCase @@ -20,7 +21,6 @@ import com.tangem.features.onramp.selectcurrency.entity.CurrencyListController import com.tangem.features.onramp.selectcurrency.entity.transformer.UpdateCurrencyItemsErrorTransformer import com.tangem.features.onramp.selectcurrency.entity.transformer.UpdateCurrencyItemsLoadingTransformer import com.tangem.features.onramp.selectcurrency.entity.transformer.UpdateCurrencyItemsTransformer -import com.tangem.features.onramp.utils.InputManager import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer import com.tangem.features.onramp.utils.sendOnrampErrorEvent diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/ui/SelectCurrencyBottomSheet.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/ui/SelectCurrencyBottomSheet.kt index 472d72efcb..8bcf48db89 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/ui/SelectCurrencyBottomSheet.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selectcurrency/ui/SelectCurrencyBottomSheet.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.util.fastForEach import coil.compose.AsyncImage import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.TextShimmer -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.buttons.SecondarySmallButton diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt index 9888b7dddb..f4fe8672a8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/DefaultOnrampOperationComponent.kt @@ -6,7 +6,6 @@ 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.features.onramp.OnrampFeatureToggles import com.tangem.features.onramp.hottokens.HotCryptoComponent import com.tangem.features.onramp.selecttoken.model.OnrampOperationModel import com.tangem.features.onramp.selecttoken.ui.OnrampSelectToken @@ -20,7 +19,6 @@ internal class DefaultOnrampOperationComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, onrampTokenListComponentFactory: OnrampTokenListComponent.Factory, @Assisted private val params: OnrampOperationComponent.Params, - private val onrampFeatureToggles: OnrampFeatureToggles, private val hotCryptoComponentFactory: HotCryptoComponent.Factory, ) : AppComponentContext by appComponentContext, OnrampOperationComponent { @@ -54,7 +52,7 @@ internal class DefaultOnrampOperationComponent @AssistedInject constructor( } private fun createHotCryptoComponent(): HotCryptoComponent? { - return if (onrampFeatureToggles.isHotTokensEnabled && params is OnrampOperationComponent.Params.Buy) { + return if (params is OnrampOperationComponent.Params.Buy) { hotCryptoComponentFactory.create( context = child(key = "hot_crypto"), params = HotCryptoComponent.Params( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt index 770e77d257..e2f4d63942 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt @@ -20,6 +20,7 @@ import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.OnrampFeatureToggles import com.tangem.features.onramp.impl.R @@ -79,13 +80,13 @@ internal class OnrampOperationModel @Inject constructor( fun onHotTokenClick(status: CryptoCurrencyStatus) { modelScope.launch { - val isAvailable = rampStateManager.availableForBuy( + val unavailabilityReason = rampStateManager.availableForBuy( scanResponse = selectedUserWallet.scanResponse, userWalletId = params.userWalletId, cryptoCurrency = status.currency, ) - if (isAvailable) { + if (unavailabilityReason == ScenarioUnavailabilityReason.None) { analyticsEventHandler.send( event = MainScreenAnalyticsEvent.HotTokenClicked(currencySymbol = status.currency.symbol), ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt index 34f4ff9f39..622c7055ba 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/success/model/OnrampSuccessComponentModel.kt @@ -19,6 +19,7 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction import com.tangem.domain.onramp.model.error.OnrampError import com.tangem.domain.tokens.GetCryptoCurrencyUseCase import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.onramp.component.OnrampSuccessComponent import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.success.entity.OnrampSuccessClickIntents @@ -42,6 +43,7 @@ internal class OnrampSuccessComponentModel @Inject constructor( private val getOnrampStatusUseCase: GetOnrampStatusUseCase, private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, private val onrampRemoveTransactionUseCase: OnrampRemoveTransactionUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val messageSender: UiMessageSender, private val router: Router, @@ -79,17 +81,21 @@ internal class OnrampSuccessComponentModel @Inject constructor( } private suspend fun loadTransactionStatus(transaction: OnrampTransaction) { - val multiCryptoCurrency = getCryptoCurrencyUseCase( - transaction.userWalletId, - transaction.toCurrencyId, - ).getOrNull() - - val cryptoCurrency = multiCryptoCurrency - ?: getCryptoCurrencyUseCase.invoke(transaction.userWalletId).getOrElse { - Timber.e("Crypto currency not found") - showErrorAlert(OnrampError.DomainError(null)) - return - } + val userWallet = getUserWalletUseCase(transaction.userWalletId).getOrNull() + if (userWallet == null) { + Timber.e("UserWallet found") + // this case should never happened + showErrorAlert(OnrampError.DomainError("UserWallet not found")) + return + } + val cryptoCurrency = getCryptoCurrencyUseCase( + userWallet = userWallet, + cryptoCurrencyId = transaction.toCurrencyId, + ).getOrElse { + Timber.e("Crypto currency not found") + showErrorAlert(OnrampError.DomainError(null)) + return + } getOnrampStatusUseCase(txId = transaction.txId) .fold( diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index d31bb7513d..09f41bc29d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.onramp.swap.availablepairs.model import arrow.core.getOrElse import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.extensions.capitalize import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList @@ -31,7 +32,6 @@ import com.tangem.features.onramp.tokenlist.entity.TokenListUMController import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer -import com.tangem.features.onramp.utils.InputManager import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer @@ -63,7 +63,7 @@ internal class AvailableSwapPairsModel @Inject constructor( private val availablePairsByNetworkFlow = MutableStateFlow>(emptyMap()) init { - initializeSearchBardCallbacks() + initializeSearchBarCallbacks() subscribeOnUpdateState() subscribeOnAvailablePairsUpdates() @@ -82,7 +82,7 @@ internal class AvailableSwapPairsModel @Inject constructor( .shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1) } - private fun initializeSearchBardCallbacks() { + private fun initializeSearchBarCallbacks() { tokenListUMController.update( transformer = UpdateSearchBarCallbacksTransformer( onQueryChange = ::onSearchQueryChange, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt index 0491aa226a..b54f7d0386 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt @@ -68,7 +68,7 @@ internal object OnrampTokenItemStateConverterFactory { is CryptoCurrencyStatus.NoAccount, -> { TokenItemState.Subtitle2State.TextContent( - text = status.getFormattedCryptoAmount(includeStaking = false), + text = status.getFormattedCryptoAmount(), isFlickering = status.value.isFlickering(), ) } @@ -92,7 +92,7 @@ internal object OnrampTokenItemStateConverterFactory { is CryptoCurrencyStatus.NoAccount, -> { TokenItemState.FiatAmountState.TextContent( - text = status.getFormattedFiatAmount(appCurrency = appCurrency, includeStaking = false), + text = status.getFormattedFiatAmount(appCurrency = appCurrency), isAvailable = isAvailable, isFlickering = status.value.isFlickering(), ) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index 76809b1485..ee6457f6af 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -4,19 +4,20 @@ import arrow.core.getOrElse import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.components.fields.InputManager import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.utils.getOrElse -import com.tangem.domain.exchange.ExchangeableState import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.tokens.GetTokenListUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenList import com.tangem.domain.tokens.model.TotalFiatBalance import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -28,7 +29,6 @@ import com.tangem.features.onramp.tokenlist.entity.TokenListUMController import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateTokenItemsTransformer -import com.tangem.features.onramp.utils.InputManager import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer @@ -230,7 +230,7 @@ internal class OnrampTokenListModel @Inject constructor( scanResponse = scanResponse, userWalletId = params.userWalletId, cryptoCurrency = status.currency, - ) + ).isAvailable() } OnrampOperation.SELL -> { rampStateManager.availableForSell( @@ -242,21 +242,14 @@ internal class OnrampTokenListModel @Inject constructor( val isAvailable = rampStateManager.availableForSwap( userWalletId = params.userWalletId, cryptoCurrency = status.currency, - ).isSwapAvailable() && !status.currency.isCustom + ).isAvailable() && !status.currency.isCustom isAvailable && status.value !is CryptoCurrencyStatus.NoQuote } } } - private fun ExchangeableState.isSwapAvailable(): Boolean { - return when (this) { - ExchangeableState.AssetNotFound, - ExchangeableState.Error, - ExchangeableState.Loading, - ExchangeableState.NotExchangeable, - -> false - ExchangeableState.Exchangeable -> true - } + private fun ScenarioUnavailabilityReason.isAvailable(): Boolean { + return this == ScenarioUnavailabilityReason.None } } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index 679abe3866..c45da297d2 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -6,11 +6,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -18,6 +16,7 @@ import androidx.compose.ui.util.fastForEachIndexed import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.fields.SearchBar +import com.tangem.core.ui.components.fields.TangemSearchBarDefaults import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.components.tokenlist.TokenListItem @@ -74,16 +73,7 @@ internal fun TokenList(state: TokenListUM, modifier: Modifier = Modifier) { private fun SearchBar(searchBarUM: SearchBarUM) { SearchBar( state = searchBarUM, - colors = TextFieldDefaults.colors().copy( - focusedContainerColor = TangemTheme.colors.field.focused, - unfocusedContainerColor = TangemTheme.colors.field.focused, - focusedTextColor = TangemTheme.colors.text.primary1, - unfocusedTextColor = TangemTheme.colors.text.primary1, - cursorColor = TangemTheme.colors.icon.primary1, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), + colors = TangemSearchBarDefaults.secondaryTextFieldColors, ) } diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/model/QrScanningModel.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/model/QrScanningModel.kt index b120a8bd59..505a42e7ec 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/model/QrScanningModel.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/model/QrScanningModel.kt @@ -8,6 +8,7 @@ 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.navigation.settings.SettingsManager +import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.domain.qrscanning.usecases.EmitQrScannedEventUseCase import com.tangem.feature.qrscanning.QrScanningComponent @@ -34,6 +35,7 @@ internal class QrScanningModel @Inject constructor( private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase, private val settingsManager: SettingsManager, private val appRouter: AppRouter, + private val clipboardManager: ClipboardManager, ) : Model(), QrScanningClickIntents { private val params = paramsContainer.require() @@ -49,7 +51,14 @@ internal class QrScanningModel @Inject constructor( // samsung for some reason disables reader mode, and then it works unstable // to prevent this disable ir manually before scan QR cardSdkProvider.sdk.forceDisableReaderMode() - stateHolder.update(InitializeQrScanningStateTransformer(this, params.source, params.networkName)) + stateHolder.update( + InitializeQrScanningStateTransformer( + clickIntents = this, + clipboardManager = clipboardManager, + source = params.source, + network = params.networkName, + ), + ) } fun onCameraDeniedState() { diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningCameraDeniedBottomSheet.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningCameraDeniedBottomSheet.kt index dd1f555379..ca29304e81 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningCameraDeniedBottomSheet.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningCameraDeniedBottomSheet.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.SimpleSettingsRow -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt index 019b7b6134..da9c43577f 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt @@ -6,9 +6,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -22,11 +20,16 @@ import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.TopAppBarButton import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults 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.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.qrscanning.impl.R @@ -57,6 +60,7 @@ internal fun QrScanningContent( ) Overlay( message = uiState.message, + pasteAction = uiState.pasteAction, ) TangemTopAppBar( @@ -97,69 +101,100 @@ internal fun QrScanningContent( } } +@Suppress("LongMethod") @Composable -private fun Overlay(message: TextReference?, overlayColor: Color = TangemColorPalette.Black.copy(alpha = 0.7f)) { +private fun Overlay( + message: TextReference?, + pasteAction: PasteAction, + overlayColor: Color = TangemColorPalette.Black.copy(alpha = 0.7f), +) { val whiteColor = TangemTheme.colors.text.constantWhite val borderWidth = with(LocalDensity.current) { TangemTheme.dimens.spacing4.toPx() } val borderLength = with(LocalDensity.current) { TangemTheme.dimens.size20.toPx() } val textMeasurer = rememberTextMeasurer() - val textPadding = with(LocalDensity.current) { TangemTheme.dimens.spacing24.toPx() } - val style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Center) + val textPadding = with(LocalDensity.current) { TangemTheme.dimens.spacing48.toPx() } + val style = TangemTheme.typography.body1.copy(textAlign = TextAlign.Center) val text = message?.resolveReference() - Canvas(modifier = Modifier.fillMaxSize()) { - val squareSize = size.minDimension * QR_CODE_SIZE - val verticalOffset = (size.height - squareSize) / 2f - val horizontalOffset = (size.width - squareSize) / 2f - drawRect( - color = overlayColor, - topLeft = Offset.Zero, - size = Size(size.width, verticalOffset), - ) - drawRect( - color = overlayColor, - topLeft = Offset(0f, verticalOffset), - size = Size(horizontalOffset, squareSize), - ) - drawRect( - color = overlayColor, - topLeft = Offset(horizontalOffset + squareSize, verticalOffset), - size = Size(horizontalOffset, squareSize), - ) - drawRect( - color = overlayColor, - topLeft = Offset(0f, verticalOffset + squareSize), - size = Size(size.width, verticalOffset), - ) - drawRect( - color = whiteColor, - topLeft = Offset(horizontalOffset, verticalOffset), - size = Size(squareSize, squareSize), - style = Stroke( - width = borderWidth, - join = StrokeJoin.Round, - pathEffect = PathEffect.dashPathEffect( - intervals = floatArrayOf(2 * borderLength, squareSize - 2 * borderLength), - phase = borderLength, + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val minDimension = minOf(this.constraints.minWidth, this.constraints.minHeight) + val squareSize = minDimension * QR_CODE_SIZE + val squareSizeInDp = with(LocalDensity.current) { squareSize.toDp() } + val verticalOffset = (constraints.minHeight - squareSize) / 2f + val horizontalOffset = (constraints.minWidth - squareSize) / 2f + + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect( + color = overlayColor, + topLeft = Offset.Zero, + size = Size(size.width, verticalOffset), + ) + drawRect( + color = overlayColor, + topLeft = Offset(0f, verticalOffset), + size = Size(horizontalOffset, squareSize), + ) + drawRect( + color = overlayColor, + topLeft = Offset(horizontalOffset + squareSize, verticalOffset), + size = Size(horizontalOffset, squareSize), + ) + drawRect( + color = overlayColor, + topLeft = Offset(0f, verticalOffset + squareSize), + size = Size(size.width, verticalOffset), + ) + drawRect( + color = whiteColor, + topLeft = Offset(horizontalOffset, verticalOffset), + size = Size(squareSize, squareSize), + style = Stroke( + width = borderWidth, + join = StrokeJoin.Round, + pathEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf(2 * borderLength, squareSize - 2 * borderLength), + phase = borderLength, + ), ), - ), - ) - text?.let { - val textLayoutResult = text.let { - textMeasurer.measure( - text = it, - style = style, - constraints = Constraints.fixedWidth(squareSize.roundToInt()), + ) + text?.let { + val textLayoutResult = text.let { + textMeasurer.measure( + text = it, + style = style, + constraints = Constraints.fixedWidth(squareSize.roundToInt()), + ) + } + drawText( + textLayoutResult = textLayoutResult, + color = whiteColor, + topLeft = Offset( + x = horizontalOffset, + y = verticalOffset - textPadding - textLayoutResult.size.height, + ), ) } - drawText( - textLayoutResult = textLayoutResult, - color = whiteColor, - topLeft = Offset( - x = horizontalOffset, - y = verticalOffset + squareSize + textPadding, - ), + } + if (pasteAction is PasteAction.Perform) { + TangemButton( + modifier = Modifier + .offset( + offset = { + IntOffset( + x = horizontalOffset.roundToInt(), + y = (verticalOffset + squareSize + textPadding).roundToInt(), + ) + }, + ) + .width(squareSizeInDp), + text = stringResourceSafe(id = R.string.wallet_connect_paste_from_clipboard), + colors = TangemButtonsDefaults.defaultTextButtonColors.copy(contentColor = whiteColor), + onClick = pasteAction.action, + textStyle = TangemTheme.typography.subtitle1, + icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_copy_24), + showProgress = false, + enabled = true, ) } } diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningState.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningState.kt index e9ca30f4e1..b18cf507af 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningState.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningState.kt @@ -5,10 +5,17 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference @Immutable -data class QrScanningState( +internal data class QrScanningState( val message: TextReference?, val onQrScanned: (String) -> Unit, val onBackClick: () -> Unit, val onGalleryClick: () -> Unit, + val pasteAction: PasteAction = PasteAction.None, val bottomSheetConfig: TangemBottomSheetConfig? = null, -) \ No newline at end of file +) + +@Immutable +internal sealed interface PasteAction { + data object None : PasteAction + class Perform(val action: () -> Unit) : PasteAction +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt index 7c6d08b333..f4124119a7 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt @@ -1,14 +1,20 @@ package com.tangem.feature.qrscanning.presentation.transformers +import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.qrscanning.models.SourceType import com.tangem.feature.qrscanning.impl.R -import com.tangem.feature.qrscanning.presentation.QrScanningState import com.tangem.feature.qrscanning.model.QrScanningClickIntents +import com.tangem.feature.qrscanning.presentation.PasteAction +import com.tangem.feature.qrscanning.presentation.QrScanningState + +private const val WC_SCHEME = "wc" internal class InitializeQrScanningStateTransformer( private val clickIntents: QrScanningClickIntents, + private val clipboardManager: ClipboardManager, private val source: SourceType, private val network: String?, ) : QrScanningTransformer { @@ -16,7 +22,7 @@ internal class InitializeQrScanningStateTransformer( override fun transform(prevState: QrScanningState): QrScanningState { val message = when (source) { SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) } - else -> null + SourceType.WALLET_CONNECT -> stringReference("Open Web3 app and chose WalletConnect option") } return QrScanningState( @@ -24,6 +30,20 @@ internal class InitializeQrScanningStateTransformer( onBackClick = clickIntents::onBackClick, onQrScanned = clickIntents::onQrScanned, onGalleryClick = clickIntents::onGalleryClicked, + pasteAction = constructPasteAction(), ) } + + private fun constructPasteAction(): PasteAction { + val uri = clipboardManager.getText() + return if (source == SourceType.WALLET_CONNECT && uri != null && isWalletConnectUri(uri)) { + PasteAction.Perform { clickIntents.onQrScanned(uri) } + } else { + PasteAction.None + } + } + + private fun isWalletConnectUri(uri: String): Boolean { + return uri.lowercase().startsWith(WC_SCHEME) + } } \ No newline at end of file diff --git a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractorImpl.kt b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractorImpl.kt index 4315d06992..c580236ad8 100644 --- a/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractorImpl.kt +++ b/features/referral/domain/src/main/java/com/tangem/feature/referral/domain/ReferralInteractorImpl.kt @@ -40,6 +40,8 @@ internal class ReferralInteractorImpl( } val cryptoCurrency = repository.getCryptoCurrency(userWalletId = userWallet.walletId, tokenData = tokenData) + ?: error("Failed to create crypto currency") + derivePublicKeysUseCase(userWallet.walletId, listOfNotNull(cryptoCurrency)).getOrElse { Timber.e("Failed to derive public keys: $it") throw it.mapToDomainError() @@ -47,12 +49,12 @@ internal class ReferralInteractorImpl( addCryptoCurrenciesUseCase( userWalletId = userWallet.walletId, - currencies = listOfNotNull(cryptoCurrency), + currency = cryptoCurrency, ) val publicAddress = userWalletManager.getWalletAddress( networkId = tokenData.networkId, - derivationPath = cryptoCurrency?.network?.derivationPath?.value, + derivationPath = cryptoCurrency.network.derivationPath.value, ) return repository.startReferral( diff --git a/features/send-v2/api/.gitignore b/features/send-v2/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/send-v2/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/send-v2/api/build.gradle.kts b/features/send-v2/api/build.gradle.kts new file mode 100644 index 0000000000..7bf806b77c --- /dev/null +++ b/features/send-v2/api/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.send.v2.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain models */ + implementation(projects.domain.wallets.models) + implementation(projects.domain.tokens.models) +} \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendComponent.kt new file mode 100644 index 0000000000..f416d89a0f --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendComponent.kt @@ -0,0 +1,20 @@ +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.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId + +interface SendComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val currency: CryptoCurrency, + val transactionId: String? = null, + val amount: String? = null, + val tag: String? = null, + val destinationAddress: String? = null, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt new file mode 100644 index 0000000000..14b404270d --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/SendFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.send.v2.api + +interface SendFeatureToggles { + val isSendV2Enabled: Boolean +} \ No newline at end of file diff --git a/features/send-v2/impl/.gitignore b/features/send-v2/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/send-v2/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/send-v2/impl/build.gradle.kts b/features/send-v2/impl/build.gradle.kts new file mode 100644 index 0000000000..6858492736 --- /dev/null +++ b/features/send-v2/impl/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.send.v2.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.sendV2.api) + implementation(projects.features.txhistory.api) + + /** Libs */ + implementation(projects.libs.crypto) + + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.core.analytics) + implementation(projects.core.configToggles) + implementation(projects.core.navigation) + api(projects.core.pagination) + + /** Tangem SDK */ + implementation(tangemDeps.blockchain) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.routing) + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.legacy) + implementation(projects.domain.tokens.models) + implementation(projects.domain.tokens) + implementation(projects.domain.wallets.models) + implementation(projects.domain.wallets) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.appCurrency) + implementation(projects.domain.transaction.models) + implementation(projects.domain.transaction) + implementation(projects.domain.txhistory.models) + implementation(projects.domain.txhistory) + implementation(projects.domain.qrScanning.models) + implementation(projects.domain.qrScanning) + implementation(projects.domain.settings) + implementation(projects.domain.feedback) + implementation(projects.domain.txhistory) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.balanceHiding) + + /** Compose libraries */ + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.material3) + implementation(deps.decompose.ext.compose) + implementation(deps.androidx.activity.compose) + implementation(deps.androidx.paging.runtime) + + /** Other dependencies */ + implementation(deps.kotlin.immutable.collections) + implementation(deps.timber) + implementation(deps.androidx.paging.runtime) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt new file mode 100644 index 0000000000..a36e1cfa6a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/DefaultSendFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.features.send.v2 + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.send.v2.api.SendFeatureToggles + +internal class DefaultSendFeatureToggles( + private val featureToggles: FeatureTogglesManager, +) : SendFeatureToggles { + override val isSendV2Enabled: Boolean + get() = featureToggles.isFeatureEnabled("SEND_V2_ENABLED") +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/NavigationUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/NavigationUM.kt new file mode 100644 index 0000000000..46b298b83c --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/NavigationUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.send.v2.common + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.send.v2.send.ui.state.ButtonsUM + +@Immutable +internal sealed class NavigationUM { + data class Content( + val title: TextReference, + val subtitle: TextReference?, + @DrawableRes val backIconRes: Int, + val backIconClick: () -> Unit, + @DrawableRes val additionalIconRes: Int? = null, + val additionalIconClick: (() -> Unit)? = null, + val primaryButton: ButtonsUM.PrimaryButtonUM, + val prevButton: ButtonsUM.PrimaryButtonUM?, + val secondaryPairButtonsUM: ButtonsUM.SecondaryPairButtonsUM?, + ) : NavigationUM() + + data object Empty : NavigationUM() +} \ 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 new file mode 100644 index 0000000000..1c2c1800c4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt @@ -0,0 +1,5 @@ +package com.tangem.features.send.v2.common + +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/di/SendFeatureModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt new file mode 100644 index 0000000000..e8d18db28b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt @@ -0,0 +1,32 @@ +package com.tangem.features.send.v2.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.send.v2.DefaultSendFeatureToggles +import com.tangem.features.send.v2.api.SendComponent +import com.tangem.features.send.v2.api.SendFeatureToggles +import com.tangem.features.send.v2.send.DefaultSendComponent +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +internal object SendFeatureModule { + + @Provides + fun provideSendFeatureToggles(featureTogglesManager: FeatureTogglesManager): SendFeatureToggles { + return DefaultSendFeatureToggles(featureTogglesManager) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal interface SendFeatureModuleBinds { + + @Binds + @Singleton + fun provideSendComponentFactory(impl: DefaultSendComponent.Factory): SendComponent.Factory +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt new file mode 100644 index 0000000000..38ff838b69 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendModelModule.kt @@ -0,0 +1,50 @@ +package com.tangem.features.send.v2.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 com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel +import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeModel +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface SendModelModule { + + @Binds + @IntoMap + @ClassKey(SendModel::class) + fun provideSendModel(model: SendModel): Model + + @Binds + @IntoMap + @ClassKey(SendAmountModel::class) + fun provideSendAmountModel(model: SendAmountModel): Model + + @Binds + @IntoMap + @ClassKey(SendDestinationModel::class) + fun provideSendDestinationModel(model: SendDestinationModel): Model + + @Binds + @IntoMap + @ClassKey(SendFeeModel::class) + fun provideSendFeeModel(model: SendFeeModel): Model + + @Binds + @IntoMap + @ClassKey(SendConfirmModel::class) + fun provideSendConfirmModel(model: SendConfirmModel): Model + + @Binds + @IntoMap + @ClassKey(NotificationsModel::class) + fun provideNotificationsModel(model: NotificationsModel): Model +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt new file mode 100644 index 0000000000..3ce42734fc --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt @@ -0,0 +1,234 @@ +package com.tangem.features.send.v2.send + +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.common.ui.amountScreen.models.AmountState +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.send.v2.api.SendComponent +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.send.confirm.SendConfirmComponent +import com.tangem.features.send.v2.send.confirm.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.model.SendModel +import com.tangem.features.send.v2.send.ui.SendContent +import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponent +import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams +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 + +internal class DefaultSendComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: SendComponent.Params, + private val analyticsEventHandler: AnalyticsEventHandler, +) : SendComponent, AppComponentContext by appComponentContext { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val initialRoute = if (params.amount == null) { + SendRoute.Destination(isEditMode = false) + } else { + SendRoute.Empty + } + private val currentRoute = MutableStateFlow(initialRoute) + + private val model: SendModel = getOrCreateModel(params = params, router = innerRouter) + + 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 { + when (val activeComponent = stack.active.instance) { + is SendConfirmComponent -> if (currentRoute.value.isEditMode) { + analyticsEventHandler.send(SendAnalyticEvents.ConfirmationScreenOpened) + activeComponent.updateState(model.uiState.value) + } + is SendAmountComponent -> { + analyticsEventHandler.send(SendAnalyticEvents.AmountScreenOpened) + activeComponent.updateState(model.uiState.value.amountUM) + } + is SendDestinationComponent -> { + analyticsEventHandler.send(SendAnalyticEvents.AddressScreenOpened) + activeComponent.updateState(model.uiState.value.destinationUM) + } + is SendFeeComponent -> { + analyticsEventHandler.send(SendAnalyticEvents.FeeScreenOpened) + } + } + currentRoute.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( + state = state, + 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 getDestinationComponent(factoryContext: AppComponentContext, route: SendRoute) = + SendDestinationComponent( + appComponentContext = factoryContext, + params = SendDestinationComponentParams.DestinationParams( + state = model.uiState.value.destinationUM, + currentRoute = currentRoute.filterIsInstance(), + isBalanceHidingFlow = model.isBalanceHiddenFlow, + analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, + userWalletId = params.userWalletId, + cryptoCurrency = params.currency, + callback = model, + isEditMode = route.isEditMode, + ), + ) + + private fun getAmountComponent(factoryContext: AppComponentContext, route: SendRoute) = SendAmountComponent( + appComponentContext = factoryContext, + params = SendAmountComponentParams.AmountParams( + state = model.uiState.value.amountUM, + 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, + ), + ) + + private fun getFeeComponent(factoryContext: AppComponentContext): ComposableContentComponent { + val state = model.uiState.value + val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value + val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value + return if (sendAmount != null && destinationAddress != null) { + SendFeeComponent( + appComponentContext = factoryContext, + params = SendFeeComponentParams.FeeParams( + state = model.uiState.value.feeUM, + currentRoute = currentRoute.filterIsInstance(), + analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, + userWallet = model.userWallet, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, + appCurrency = model.appCurrency, + sendAmount = sendAmount, + destinationAddress = destinationAddress, + callback = model, + ), + ) + } else { + getStubComponent() + } + } + + private fun getConfirmComponent(factoryContext: AppComponentContext): SendConfirmComponent { + val predefinedAmount = params.amount + val predefinedTxId = params.transactionId + val predefinedAddress = params.destinationAddress + val predefinedValues = + if (predefinedAmount != null && predefinedTxId != null && predefinedAddress != null) { + SendConfirmComponent.Params.PredefinedValues.Content( + amount = predefinedAmount, + address = predefinedAddress, + tag = params.tag, + transactionId = predefinedTxId, + ) + } else { + SendConfirmComponent.Params.PredefinedValues.Empty + } + return SendConfirmComponent( + appComponentContext = factoryContext, + params = SendConfirmComponent.Params( + state = model.uiState.value, + userWallet = model.userWallet, + currentRoute = currentRoute.filterIsInstance(), + isBalanceHidingFlow = model.isBalanceHiddenFlow, + analyticsCategoryName = SendAnalyticEvents.SEND_CATEGORY, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, + appCurrency = model.appCurrency, + callback = model, + predefinedValues = predefinedValues, + ), + ) + } + + private fun getStubComponent() = ComposableContentComponent { } + + private fun onChildBack() { + val isEmptyRoute = childStack.value.active.configuration == SendRoute.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 : SendComponent.Factory { + override fun create(context: AppComponentContext, params: SendComponent.Params): DefaultSendComponent + } +} \ No newline at end of file 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/send/SendRoute.kt new file mode 100644 index 0000000000..078366292e --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/SendRoute.kt @@ -0,0 +1,35 @@ +package com.tangem.features.send.v2.send + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class SendRoute : Route { + + abstract val isEditMode: Boolean + + @Serializable + data object Empty : SendRoute() { + override val isEditMode = false + } + + @Serializable + data object Confirm : SendRoute() { + override val isEditMode: Boolean = false + } + + @Serializable + data class Destination( + override val isEditMode: Boolean, + ) : SendRoute() + + @Serializable + data class Amount( + override val isEditMode: Boolean, + ) : SendRoute() + + @Serializable + data class Fee( + override val isEditMode: Boolean = true, + ) : SendRoute() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt new file mode 100644 index 0000000000..aca97719f1 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt @@ -0,0 +1,91 @@ +package com.tangem.features.send.v2.send.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TYPE +import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM + +/** + * Send screen analytics + */ +internal sealed class SendAnalyticEvents( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = SEND_CATEGORY, event = event, params = params) { + + /** Recipient address screen opened */ + data object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened") + + /** Amount screen opened */ + data object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened") + + /** Fee screen opened */ + data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened") + + /** Confirmation screen opened */ + data object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened") + + /** Transaction send screen opened */ + data class TransactionScreenOpened( + val token: String, + val feeType: AnalyticsParam.FeeType, + ) : SendAnalyticEvents( + event = "Transaction Sent Screen Opened", + params = mapOf( + TOKEN_PARAM to token, + FEE_TYPE to feeType.value, + ), + ) + + /** If transaction delays notification is present */ + data class NoticeTransactionDelays( + val token: String, + ) : SendAnalyticEvents( + event = "Notice - Transaction Delays Are Possible", + params = mapOf(TOKEN_PARAM to token), + ) + + /** If error occurs during send transactions */ + data class TransactionError(val token: String) : SendAnalyticEvents( + event = "Error - Transaction Rejected", + params = mapOf(TOKEN_PARAM to token), + ) + + /** Close button clicked */ + data class CloseButtonClicked( + val source: SendScreenSource, + val isFromSummary: Boolean, + val isValid: Boolean, + ) : SendAnalyticEvents( + event = "Button - Close", + params = mapOf( + SOURCE to source.name, + "FromSummary" to if (isFromSummary) "Yes" else "No", + "isValid" to if (isValid) "Yes" else "No", + ), + ) + + /** Share button clicked */ + data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share") + + /** Expore button clicked */ + data object ExploreButtonClicked : SendAnalyticEvents(event = "Button - Explore") + + /** Screen reopened from confirmation screen */ + data class ScreenReopened(val source: SendScreenSource) : SendAnalyticEvents( + event = "Screen Reopened", + params = mapOf(SOURCE to source.name), + ) + + companion object { + const val SEND_CATEGORY = "Token / Send" + } + + internal enum class SendScreenSource { + Address, + Amount, + Fee, + Confirm, + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt new file mode 100644 index 0000000000..73f04ff63b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt @@ -0,0 +1,53 @@ +package com.tangem.features.send.v2.send.analytics + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import javax.inject.Inject + +@ModelScoped +internal class SendAnalyticHelper @Inject constructor( + private val analyticsEventHandler: AnalyticsEventHandler, +) { + + fun sendSuccessAnalytics(cryptoCurrency: CryptoCurrency, sendUM: SendUM) { + val destinationUM = sendUM.destinationUM as? DestinationUM.Content + val feeUM = sendUM.feeUM as? FeeUM.Content + val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content ?: return + val feeType = feeSelectorUM.selectedType.toAnalyticType(feeSelectorUM) + analyticsEventHandler.send( + SendAnalyticEvents.TransactionScreenOpened( + token = cryptoCurrency.symbol, + feeType = feeType, + ), + ) + analyticsEventHandler.send( + Basic.TransactionSent( + sentFrom = AnalyticsParam.TxSentFrom.Send( + blockchain = cryptoCurrency.network.name, + token = cryptoCurrency.symbol, + feeType = feeType, + ), + memoType = getSendTransactionMemoType(destinationUM?.memoTextField), + ), + ) + } + + private fun getSendTransactionMemoType( + recipientMemo: DestinationTextFieldUM.RecipientMemo?, + ): Basic.TransactionSent.MemoType { + val memo = recipientMemo?.value + return when { + memo?.isBlank() == true -> Basic.TransactionSent.MemoType.Empty + memo?.isNotBlank() == true -> Basic.TransactionSent.MemoType.Full + else -> Basic.TransactionSent.MemoType.Null + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..ff6ba04840 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -0,0 +1,161 @@ +package com.tangem.features.send.v2.send.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.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.send.v2.send.SendRoute +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.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.fee.SendFeeBlockComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import com.tangem.utils.extensions.orZero +import kotlinx.coroutines.flow.* + +internal class SendConfirmComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SendConfirmModel = getOrCreateModel(params = params) + + private val blockClickEnableFlow = MutableStateFlow(false) + + private val destinationBlockComponent = + SendDestinationBlockComponent( + appComponentContext = child("sendConfirmDestinationBlock"), + params = SendDestinationComponentParams.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, + ), + onResult = model::onDestinationResult, + onClick = model::showEditDestination, + ) + + private val amountBlockComponent = SendAmountBlockComponent( + appComponentContext = child("sendConfirmAmountBlock"), + params = SendAmountComponentParams.AmountBlockParams( + state = model.uiState.value.amountUM, + analyticsCategoryName = params.analyticsCategoryName, + userWallet = params.userWallet, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + appCurrency = params.appCurrency, + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + isPredefinedValues = params.predefinedValues is Params.PredefinedValues.Content, + predefinedAmountValue = (params.predefinedValues as? Params.PredefinedValues.Content)?.amount, + ), + onResult = model::onAmountResult, + onClick = model::showEditAmount, + ) + + private val feeBlockComponent = SendFeeBlockComponent( + appComponentContext = child("sendConfirmFeeBlock"), + params = SendFeeComponentParams.FeeBlockParams( + state = model.uiState.value.feeUM, + analyticsCategoryName = params.analyticsCategoryName, + userWallet = params.userWallet, + cryptoCurrencyStatus = params.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus, + appCurrency = params.appCurrency, + sendAmount = model.enteredAmount.orZero(), + destinationAddress = model.enteredDestination.orEmpty(), + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + ), + onResult = model::onFeeResult, + onClick = model::showEditFee, + ) + + private val notificationsComponent = NotificationsComponent( + appComponentContext = child("sendConfirmNotifications"), + params = NotificationsComponent.Params( + analyticsCategoryName = params.analyticsCategoryName, + userWalletId = params.userWallet.walletId, + 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, + ), + ) + + init { + model.uiState.onEach { state -> + val confirmUM = state.confirmUM as? ConfirmUM.Content + blockClickEnableFlow.value = confirmUM?.isSending == false + }.launchIn(componentScope) + } + + fun updateState(state: SendUM) { + destinationBlockComponent.updateState(state.destinationUM) + amountBlockComponent.updateState(state.amountUM) + 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() + + SendConfirmContent( + sendUM = state, + destinationBlockComponent = destinationBlockComponent, + amountBlockComponent = amountBlockComponent, + feeBlockComponent = feeBlockComponent, + notificationsComponent = notificationsComponent, + notificationsUM = notificationState, + ) + } + + data class Params( + val state: SendUM, + val analyticsCategoryName: String, + val userWallet: UserWallet, + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + val appCurrency: AppCurrency, + val callback: ModelCallback, + 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) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmAlertFactory.kt new file mode 100644 index 0000000000..7eb09a280d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmAlertFactory.kt @@ -0,0 +1,63 @@ +package com.tangem.features.send.v2.send.confirm.model + +import com.tangem.common.ui.alerts.TransactionErrorAlertConverter +import com.tangem.common.ui.alerts.models.AlertDemoModeUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.features.send.v2.impl.R +import javax.inject.Inject + +@ModelScoped +internal class SendConfirmAlertFactory @Inject constructor( + private val messageSender: UiMessageSender, +) { + + fun getGenericErrorState(onFailedTxEmailClick: () -> Unit) { + messageSender.send( + DialogMessage( + title = resourceReference(id = R.string.send_alert_transaction_failed_title), + message = resourceReference(id = R.string.common_unknown_error), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_support), + onClick = onFailedTxEmailClick, + ), + ), + ) + } + + fun getSendTransactionErrorState( + error: SendTransactionError?, + popBack: () -> Unit, + onFailedTxEmailClick: (String) -> Unit, + ) { + val transactionErrorAlertConverter = TransactionErrorAlertConverter( + popBackStack = popBack, + onFailedTxEmailClick = onFailedTxEmailClick, + ) + + val errorAlert = error?.let { transactionErrorAlertConverter.convert(error) } ?: return + val onConfirmClick = errorAlert.onConfirmClick ?: return + + messageSender.send( + DialogMessage( + title = errorAlert.title, + message = errorAlert.message, + firstActionBuilder = { + EventMessageAction( + title = errorAlert.confirmButtonText, + onClick = onConfirmClick, + ) + }, + secondActionBuilder = if (errorAlert !is AlertDemoModeUM) { + { cancelAction() } + } else { + null + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmClickIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmClickIntents.kt new file mode 100644 index 0000000000..929fae6819 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmClickIntents.kt @@ -0,0 +1,18 @@ +package com.tangem.features.send.v2.send.confirm.model + +internal interface SendConfirmClickIntents { + + fun showEditDestination() + + fun showEditAmount() + + 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/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt new file mode 100644 index 0000000000..9d8f6b0816 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -0,0 +1,559 @@ +package com.tangem.features.send.v2.send.confirm.model + +import android.os.SystemClock +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 +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.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.BlockchainErrorInfo +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.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 +import com.tangem.features.send.v2.send.confirm.SendConfirmComponent +import com.tangem.features.send.v2.send.confirm.model.transformers.SendConfirmInitialStateTransformer +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 +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.model.checkAndCalculateSubtractedAmount +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 timber.log.Timber +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LongParameterList", "LargeClass") +@Stable +@ModelScoped +internal class SendConfirmModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, + private val appRouter: AppRouter, + private val router: Router, + private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase, + private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase, + private val createTransactionUseCase: CreateTransactionUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, + private val getCardInfoUseCase: GetCardInfoUseCase, + 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, +) : Model(), SendConfirmClickIntents { + + private val params: SendConfirmComponent.Params = paramsContainer.require() + + private val userWallet = params.userWallet + private val appCurrency = params.appCurrency + private val cryptoCurrencyStatus = params.cryptoCurrencyStatus + private val cryptoCurrency = cryptoCurrencyStatus.currency + + private val _uiState = MutableStateFlow(params.state) + val uiState = _uiState.asStateFlow() + + private val amountState + get() = uiState.value.amountUM as? AmountState.Data + 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 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 + + private var sendIdleTimer: Long = 0L + private var isAmountSubtractAvailable = false + + init { + modelScope.launch { + isAmountSubtractAvailable = + isAmountSubtractAvailableUseCase(userWallet.walletId, cryptoCurrency).getOrElse { false } + } + configConfirmNavigation() + subscribeOnNotificationsUpdateTrigger() + subscribeOnCheckFeeResultUpdates() + initialState() + } + + fun updateState(state: SendUM) { + _uiState.value = state + updateConfirmNotifications() + } + + fun onFeeResult(feeUM: FeeUM) { + sendIdleTimer = SystemClock.elapsedRealtime() + _uiState.update { it.copy(feeUM = feeUM) } + updateConfirmNotifications() + } + + fun onAmountResult(amountUM: AmountState) { + _uiState.update { it.copy(amountUM = amountUM) } + 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(SendRoute.Destination(isEditMode = true)) + } + } + + override fun showEditAmount() { + 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.Amount)) + router.push(SendRoute.Amount(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(SendRoute.Fee()) + } + } + + override fun onSendClick() { + _uiState.update(SendConfirmSendingStateTransformer(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) { + val amountValue = amountState?.amountTextField?.cryptoAmount?.value + val feeValue = fee?.amount?.value + + val receivingAmount = if (amountValue != null && feeValue != null) { + checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = enteredAmount.orZero(), + feeValue = feeValue, + reduceAmountBy = reduceAmountBy, + ) + } else { + null + } + + val amount = receivingAmount?.convertToSdkAmount(cryptoCurrency) + + saveBlockchainErrorUseCase( + error = BlockchainErrorInfo( + errorMessage = errorMessage, + blockchainId = cryptoCurrency.network.id.value, + derivationPath = cryptoCurrency.network.derivationPath.value, + destinationAddress = enteredDestination.orEmpty(), + tokenSymbol = if (amount?.type is AmountType.Token) { + amount.currencySymbol + } else { + "" + }, + amount = amount?.value?.stripZeroPlainString() ?: "unknown", + fee = feeValue?.convertToSdkAmount(cryptoCurrency) + ?.value?.stripZeroPlainString() ?: "unknown", + ), + ) + + val cardInfo = getCardInfoUseCase(userWallet.scanResponse).getOrNull() ?: return + + modelScope.launch { + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + } + } + + private fun initialState() { + val confirmUM = uiState.value.confirmUM + + modelScope.launch { + val isShowTapHelp = isSendTapHelpEnabledUseCase().getOrElse { false } + if (confirmUM is ConfirmUM.Empty) { + _uiState.update { + it.copy( + confirmUM = SendConfirmInitialStateTransformer( + 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() { + val amountValue = amountState?.amountTextField?.cryptoAmount?.value ?: return + val destination = destinationUM?.addressTextField?.value ?: return + val memo = destinationUM?.memoTextField?.value + val fee = feeSelectorUM?.selectedFee + val feeValue = fee?.amount?.value ?: return + + val receivingAmount = checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy.orZero(), + ) + + modelScope.launch { + createTransactionUseCase( + amount = receivingAmount.convertToSdkAmount(cryptoCurrency), + fee = fee, + memo = memo, + destination = destination, + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ).fold( + ifLeft = { error -> + Timber.e(error) + _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) + alertFactory.getGenericErrorState { + onFailedTxEmailClick(error.localizedMessage.orEmpty()) + } + }, + ifRight = { txData -> + sendTransaction(txData) + }, + ) + } + } + + private suspend fun sendTransaction(txData: TransactionData.Uncompiled) { + val result = sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = cryptoCurrency.network, + ) + + _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) + + result.fold( + ifLeft = { error -> + alertFactory.getSendTransactionErrorState( + error = error, + popBack = appRouter::pop, + onFailedTxEmailClick = ::onFailedTxEmailClick, + ) + analyticsEventHandler.send(SendAnalyticEvents.TransactionError(cryptoCurrency.symbol)) + }, + ifRight = { + updateTransactionStatus(txData) + addTokenToWalletIfNeeded() + scheduleUpdates() + sendAnalyticHelper.sendSuccessAnalytics(cryptoCurrency, uiState.value) + }, + ) + } + + private fun addTokenToWalletIfNeeded() { + if (cryptoCurrency !is CryptoCurrency.Token) return + val wallets = destinationUM?.wallets ?: return + + val receivingUserWallet = wallets + .firstOrNull { it.address == enteredDestination } + ?: return + + val userWalletId = receivingUserWallet.userWalletId ?: return + val network = receivingUserWallet.network ?: return + + modelScope.launch { + addCryptoCurrenciesUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + network = network, + ) + } + } + + private suspend fun updateTransactionStatus(txData: TransactionData.Uncompiled) { + val txUrl = getExplorerTransactionUrlUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + ).getOrElse { "" } + _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) { + sendIdleTimer = SystemClock.elapsedRealtime() + _uiState.update(SendConfirmSendingStateTransformer(isSending = true)) + verifyAndSendTransaction() + } else { + _uiState.update(SendConfirmSendingStateTransformer(isSending = false)) + } + }.launchIn(modelScope) + } + + private fun updateConfirmNotifications() { + modelScope.launch { + notificationsUpdateTrigger.triggerUpdate( + data = NotificationData( + destinationAddress = enteredDestination.orEmpty(), + memo = enteredMemo, + amountValue = enteredAmount.orZero(), + reduceAmountBy = reduceAmountBy.orZero(), + isIgnoreReduce = isIgnoreReduce, + fee = fee, + feeError = feeError, + ), + ) + _uiState.update { + it.copy( + confirmUM = SendConfirmationNotificationsTransformer( + feeUM = uiState.value.feeUM, + amountUM = uiState.value.amountUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrencyStatus.currency, + appCurrency = appCurrency, + ).transform(uiState.value.confirmUM), + ) + } + } + } + + private fun configConfirmNavigation() { + combine( + flow = uiState, + flow2 = params.currentRoute, + transform = { state, route -> state to route }, + ).onEach { (state, _) -> + val amountUM = state.amountUM as? AmountState.Data + 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 = amountUM?.title, + 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 + 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 new file mode 100644 index 0000000000..c165ade1ff --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmInitialStateTransformer.kt @@ -0,0 +1,19 @@ +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.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +internal class SendConfirmInitialStateTransformer( + 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/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 new file mode 100644 index 0000000000..e632f7b86f --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSendingStateTransformer.kt @@ -0,0 +1,19 @@ +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.send.ui.state.SendUM +import com.tangem.utils.transformer.Transformer + +internal class SendConfirmSendingStateTransformer( + val isSending: Boolean, +) : Transformer { + override fun transform(prevState: SendUM): SendUM { + 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/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 new file mode 100644 index 0000000000..c08c566fc2 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmSentStateTransformer.kt @@ -0,0 +1,20 @@ +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.send.ui.state.SendUM +import com.tangem.utils.transformer.Transformer + +internal class SendConfirmSentStateTransformer( + val txData: TransactionData.Uncompiled, + val txUrl: String, +) : Transformer { + override fun transform(prevState: SendUM): SendUM { + 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/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 new file mode 100644 index 0000000000..0066a9268d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformer.kt @@ -0,0 +1,167 @@ +package com.tangem.features.send.v2.send.confirm.model.transformers + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.parseToBigDecimal +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.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 SendConfirmationNotificationsTransformer( + private val feeUM: FeeUM, + private val amountUM: AmountState, + private val analyticsEventHandler: AnalyticsEventHandler, + private val cryptoCurrency: CryptoCurrency, + private val appCurrency: AppCurrency, +) : 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( + sendingFooter = getSendingFooterText(), + 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)) + } + } + + private fun getSendingFooterText(): TextReference { + val feeUM = feeUM as? FeeUM.Content + val amountUM = amountUM as? AmountState.Data + val fee = (feeUM?.feeSelectorUM as? FeeSelectorUM.Content)?.selectedFee + + if (fee == null || amountUM == null) return TextReference.EMPTY + + val fiatAmountValue = amountUM.amountTextField.fiatAmount.value + val fiatFeeValue = feeUM.rate?.let { fee.amount.value?.multiply(it) } + + val fiatSendingValue = if (feeUM.isFeeConvertibleToFiat) { + fiatFeeValue?.let { fiatAmountValue?.plus(it) } + } else { + fiatAmountValue + } + + val fiatSending = fiatSendingValue.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + val fiatFee = formatFiatFee( + amount = fee.amount, + isFeeConvertibleToFiat = feeUM.isFeeConvertibleToFiat, + isFeeApproximate = feeUM.isFeeApproximate, + ) + + return if (feeUM.isTronToken && fee is Fee.Tron) { + getTokenFeeSendingText( + fee = fee, + fiatFee = fiatFee, + fiatSending = fiatSending, + ) + } else { + resourceReference( + id = if (feeUM.isFeeConvertibleToFiat) { + R.string.send_summary_transaction_description + } else { + R.string.send_summary_transaction_description_no_fiat_fee + }, + formatArgs = wrappedList(fiatSending, fiatFee), + ) + } + } + + private fun formatFiatFee(amount: Amount?, isFeeConvertibleToFiat: Boolean, isFeeApproximate: Boolean): String { + return if (isFeeConvertibleToFiat) { + amount?.value.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + } else { + amount?.value.format { + crypto( + decimals = amount?.decimals ?: 0, + symbol = amount?.currencySymbol.orEmpty(), + ).fee( + canBeLower = isFeeApproximate, + ) + } + } + } + + private fun getTokenFeeSendingText(fee: Fee.Tron, fiatFee: String, fiatSending: String): TextReference { + val suffix = when { + fee.remainingEnergy == 0L -> { + resourceReference( + R.string.send_summary_transaction_description_suffix_including, + wrappedList(fiatFee), + ) + } + fee.feeEnergy <= fee.remainingEnergy -> { + resourceReference( + R.string.send_summary_transaction_description_suffix_fee_covered, + wrappedList(fee.feeEnergy), + ) + } + else -> { + resourceReference( + R.string.send_summary_transaction_description_suffix_fee_reduced, + wrappedList(fee.remainingEnergy), + ) + } + } + val prefix = resourceReference( + R.string.send_summary_transaction_description_prefix, + wrappedList(fiatSending), + ) + + return combinedReference(prefix, COMMA_SEPARATOR, suffix) + } + + companion object { + private val COMMA_SEPARATOR = stringReference(", ") + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f3a151a432 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/SendConfirmContent.kt @@ -0,0 +1,192 @@ +package com.tangem.features.send.v2.send.confirm.ui + +import androidx.compose.animation.AnimatedVisibility +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 +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.res.TangemTheme +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toTimeFormat +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 +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 +internal fun SendConfirmContent( + sendUM: SendUM, + destinationBlockComponent: SendDestinationBlockComponent, + amountBlockComponent: SendAmountBlockComponent, + feeBlockComponent: SendFeeBlockComponent, + notificationsComponent: NotificationsComponent, + notificationsUM: ImmutableList, +) { + val confirmUM = sendUM.confirmUM as? ConfirmUM.Content + + Column { + LazyColumn( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) { + blocks( + uiState = sendUM, + destinationBlockComponent = destinationBlockComponent, + amountBlockComponent = amountBlockComponent, + feeBlockComponent = feeBlockComponent, + ) + if (confirmUM != null) { + tapHelp(isDisplay = confirmUM.showTapHelp) + with(notificationsComponent) { + content( + state = notificationsUM, + isClickDisabled = confirmUM.isSending, + ) + } + notifications( + notifications = confirmUM.notifications, + isClickDisabled = confirmUM.isSending, + ) + } + } + SpacerHMax() + SendingText(footerText = confirmUM?.sendingFooter ?: TextReference.EMPTY) + } +} + +@Composable +private fun SendingText(footerText: TextReference, modifier: Modifier = Modifier) { + var isVisibleProxy by remember { mutableStateOf(footerText != TextReference.EMPTY) } + val keyboard by keyboardAsState() + + // the text should appear when the keyboard is closed + LaunchedEffect(footerText != TextReference.EMPTY, keyboard) { + if (footerText != TextReference.EMPTY && keyboard is Keyboard.Opened) { + return@LaunchedEffect + } + isVisibleProxy = footerText != TextReference.EMPTY + } + + AnimatedVisibility( + visible = isVisibleProxy, + modifier = modifier, + enter = slideInVertically() + fadeIn(), + exit = fadeOut(tween(durationMillis = 300)), + label = "Animate show sending state text", + ) { + Text( + text = footerText.resolveAnnotatedReference(), + textAlign = TextAlign.Center, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + ) + } +} + +private fun LazyListScope.blocks( + uiState: SendUM, + destinationBlockComponent: SendDestinationBlockComponent, + amountBlockComponent: SendAmountBlockComponent, + feeBlockComponent: SendFeeBlockComponent, +) { + item(key = BLOCKS_KEY) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AnimatedVisibility( + visible = uiState.confirmUM is ConfirmUM.Success, + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + ) { + val wrappedConfirmUM = remember(this) { uiState.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) + amountBlockComponent.Content(modifier = Modifier) + 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/confirm/ui/state/ConfirmUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/state/ConfirmUM.kt new file mode 100644 index 0000000000..ae6bc45add --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/ui/state/ConfirmUM.kt @@ -0,0 +1,31 @@ +package com.tangem.features.send.v2.send.confirm.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class ConfirmUM { + + abstract val isPrimaryButtonEnabled: Boolean + + data class Content( + override val isPrimaryButtonEnabled: Boolean = false, + val isSending: Boolean, + val showTapHelp: Boolean, + val sendingFooter: TextReference, + val notifications: ImmutableList, + ) : ConfirmUM() + + data class Success( + val transactionDate: Long, + val txUrl: String, + ) : ConfirmUM() { + override val isPrimaryButtonEnabled: Boolean = true + } + + data object Empty : ConfirmUM() { + override val isPrimaryButtonEnabled: Boolean = false + } +} \ 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 new file mode 100644 index 0000000000..a1d123dc42 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -0,0 +1,262 @@ +package com.tangem.features.send.v2.send.model + +import androidx.compose.runtime.Stable +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.common.ui.amountScreen.models.AmountState +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.utils.parseBigDecimal +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.BlockchainErrorInfo +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase +import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.error.CurrencyStatusError +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.SendComponent +import com.tangem.features.send.v2.common.NavigationUM +import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.send.confirm.model.SendConfirmAlertFactory +import com.tangem.features.send.v2.send.confirm.SendConfirmComponent +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.SendAmountComponent +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.SendDestinationInitialStateTransformer +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 com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.properties.Delegates + +internal interface SendComponentCallback : + SendAmountComponent.ModelCallback, + SendFeeComponent.ModelCallback, + SendDestinationComponent.ModelCallback, + SendConfirmComponent.ModelCallback + +@Stable +@ModelScoped +@Suppress("LongParameterList") +internal class SendModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val listenToQrScanningUseCase: ListenToQrScanningUseCase, + private val parseQrCodeUseCase: ParseQrCodeUseCase, + private val sendConfirmAlertFactory: SendConfirmAlertFactory, + private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, + private val getCardInfoUseCase: GetCardInfoUseCase, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, +) : Model(), SendComponentCallback { + + private val params: SendComponent.Params = paramsContainer.require() + private val userWalletId = params.userWalletId + private val cryptoCurrency = params.currency + + private val _uiState = MutableStateFlow(initialState()) + val uiState = _uiState.asStateFlow() + + private val _isBalanceHiddenFlow = MutableStateFlow(false) + val isBalanceHiddenFlow = _isBalanceHiddenFlow.asStateFlow() + + var userWallet: UserWallet by Delegates.notNull() + var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + var appCurrency: AppCurrency = AppCurrency.Default + var predefinedAmountValue: String? = null + + private var balanceHidingJobHolder = JobHolder() + + init { + subscribeOnBalanceHidden() + subscribeOnQRScannerResult() + subscribeOnCurrencyStatusUpdates() + initAppCurrency() + } + + override fun onNavigationResult(navigationUM: NavigationUM) { + _uiState.update { it.copy(navigationUM = navigationUM) } + } + + override fun onDestinationResult(destinationUM: DestinationUM) { + _uiState.update { it.copy(destinationUM = destinationUM) } + } + + override fun onAmountResult(amountUM: AmountState) { + _uiState.update { it.copy(amountUM = amountUM) } + } + + override fun onFeeResult(feeUM: FeeUM) { + _uiState.update { it.copy(feeUM = feeUM) } + } + + override fun onResult(sendUM: SendUM) { + _uiState.update { sendUM } + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun subscribeOnCurrencyStatusUpdates() { + modelScope.launch { + getUserWalletUseCase(params.userWalletId).fold( + ifRight = { wallet -> + userWallet = wallet + + val isSingleWalletWithToken = wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() + val isMultiCurrency = wallet.isMultiCurrency + getCurrenciesStatusUpdates( + isSingleWalletWithToken = isSingleWalletWithToken, + isMultiCurrency = isMultiCurrency, + ) + }, + ifLeft = { + sendConfirmAlertFactory.getGenericErrorState(::onFailedTxEmailClick) + return@launch + }, + ) + } + } + + private fun subscribeOnBalanceHidden() { + getBalanceHidingSettingsUseCase() + .conflate() + .distinctUntilChanged() + .onEach { + _isBalanceHiddenFlow.value = it.isBalanceHidden + } + .launchIn(modelScope) + .saveIn(balanceHidingJobHolder) + } + + private fun getCurrenciesStatusUpdates(isSingleWalletWithToken: Boolean, isMultiCurrency: Boolean) { + getCurrencyStatus( + isSingleWalletWithToken = isSingleWalletWithToken, + isMultiCurrency = isMultiCurrency, + ).onEach { maybeCryptoCurrency -> + maybeCryptoCurrency.fold( + ifRight = { cryptoCurrencyStatus -> + onDataLoaded( + currencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = getFeeCurrencyStatus(cryptoCurrencyStatus, isMultiCurrency), + ) + }, + ifLeft = { + sendConfirmAlertFactory.getGenericErrorState { + onFailedTxEmailClick(it.toString()) + } + }, + ) + }.launchIn(modelScope) + } + + private fun getCurrencyStatus( + isSingleWalletWithToken: Boolean, + isMultiCurrency: Boolean, + ): Flow> { + return if (isMultiCurrency) { + getCurrencyStatusUpdatesUseCase( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + isSingleWalletWithTokens = isSingleWalletWithToken, + ) + } else { + getPrimaryCurrencyStatusUpdatesUseCase(userWalletId = userWalletId) + } + } + + private suspend fun getFeeCurrencyStatus( + cryptoCurrencyStatus: CryptoCurrencyStatus, + isMultiCurrency: Boolean, + ): CryptoCurrencyStatus { + return if (isMultiCurrency) { + getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull() ?: cryptoCurrencyStatus + } else { + cryptoCurrencyStatus + } + } + + private fun onDataLoaded(currencyStatus: CryptoCurrencyStatus, feeCurrencyStatus: CryptoCurrencyStatus) { + cryptoCurrencyStatus = currencyStatus + feeCryptoCurrencyStatus = feeCurrencyStatus + + if (params.amount != null) { + router.replaceAll(SendRoute.Confirm) + } + } + + private fun subscribeOnQRScannerResult() { + listenToQrScanningUseCase(SourceType.SEND) + .getOrElse { emptyFlow() } + .onEach(::onQrCodeScanned) + .launchIn(modelScope) + } + + private fun onQrCodeScanned(address: String) { + val parsedQrCode = parseQrCodeUseCase(address, cryptoCurrency).getOrNull() + predefinedAmountValue = parsedQrCode?.amount?.parseBigDecimal(cryptoCurrency.decimals) + } + + private fun onFailedTxEmailClick(errorMessage: String? = null) { + saveBlockchainErrorUseCase( + error = BlockchainErrorInfo( + errorMessage = errorMessage.orEmpty(), + blockchainId = cryptoCurrency.network.id.value, + derivationPath = cryptoCurrency.network.derivationPath.value, + destinationAddress = "", + tokenSymbol = "", + amount = "", + fee = "", + ), + ) + + val cardInfo = getCardInfoUseCase(userWallet.scanResponse).getOrNull() ?: return + + modelScope.launch { + sendFeedbackEmailUseCase(type = FeedbackEmailType.TransactionSendingProblem(cardInfo = cardInfo)) + } + } + + private fun initialState(): SendUM = SendUM( + amountUM = AmountState.Empty(), + destinationUM = SendDestinationInitialStateTransformer( + cryptoCurrency = cryptoCurrency, + ).transform(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/send/ui/SendContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendContent.kt new file mode 100644 index 0000000000..8451e8d50d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendContent.kt @@ -0,0 +1,55 @@ +package com.tangem.features.send.v2.send.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.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 + +@Composable +internal fun SendContent(state: SendUM, stackState: ChildStack) { + Column( + modifier = Modifier.Companion + .background(color = TangemTheme.colors.background.tertiary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.Companion.CenterHorizontally, + ) { + SendAppBar(navigationUM = state.navigationUM) + Children( + stack = stackState, + animation = stackAnimation(slide()), + modifier = Modifier.weight(1f), + ) { + it.instance.Content(Modifier.weight(1f)) + } + SendNavigationButtons(navigationUM = state.navigationUM) + } +} + +@Composable +private fun SendAppBar(navigationUM: NavigationUM) { + val navigationUM = navigationUM as? NavigationUM.Content ?: return + AppBarWithBackButtonAndIcon( + text = navigationUM.title.resolveReference(), + subtitle = navigationUM.subtitle?.resolveReference(), + onBackClick = navigationUM.backIconClick, + onIconClick = navigationUM.additionalIconClick, + backIconRes = navigationUM.backIconRes, + iconRes = navigationUM.additionalIconRes, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendNavigationButtons.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendNavigationButtons.kt new file mode 100644 index 0000000000..f808951e95 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/SendNavigationButtons.kt @@ -0,0 +1,128 @@ +package com.tangem.features.send.v2.send.ui + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SecondaryButtonIconStart +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.buttons.common.TangemButton +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults +import com.tangem.features.send.v2.send.ui.state.ButtonsUM +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.common.NavigationUM + +@Composable +internal fun SendNavigationButtons(navigationUM: NavigationUM, modifier: Modifier = Modifier) { + val navigationUM = navigationUM as? NavigationUM.Content ?: return + + Column( + modifier = modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + SendDoneButtons(navigationUM.secondaryPairButtonsUM) + SendNavigationButton( + navigationUM = navigationUM, + ) + } +} + +@Composable +private fun SendNavigationButton(navigationUM: NavigationUM, modifier: Modifier = Modifier) { + val hapticFeedback = LocalHapticFeedback.current + val navigationUM = navigationUM as? NavigationUM.Content ?: return + val primaryButton = navigationUM.primaryButton + + Row(modifier = modifier) { + AnimatedVisibility( + visible = navigationUM.prevButton != null, + enter = expandHorizontally(expandFrom = Alignment.End), + exit = shrinkHorizontally(shrinkTowards = Alignment.End), + ) { + val wrappedNavigationUM = remember(this) { requireNotNull(navigationUM.prevButton) } + Row { + Icon( + painter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.ic_back_24)), + tint = TangemTheme.colors.icon.primary1, + contentDescription = null, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.button.secondary) + .clickable(onClick = wrappedNavigationUM.onClick) + .padding(12.dp), + ) + SpacerW12() + } + } + TangemButton( + modifier = Modifier.fillMaxWidth(), + text = primaryButton.text.resolveReference(), + icon = primaryButton.iconResId?.let { + TangemButtonIconPosition.End(it) + } ?: TangemButtonIconPosition.None, + enabled = primaryButton.isEnabled, + onClick = { + if (primaryButton.isHapticClick) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + primaryButton.onClick() + }, + showProgress = false, + colors = TangemButtonsDefaults.primaryButtonColors, + textStyle = TangemTheme.typography.subtitle1, + ) + } +} + +@Composable +private fun SendDoneButtons(pairButtonsUM: ButtonsUM.SecondaryPairButtonsUM?, modifier: Modifier = Modifier) { + val hapticFeedback = LocalHapticFeedback.current + + AnimatedVisibility( + visible = pairButtonsUM != null, + modifier = modifier, + enter = slideInVertically().plus(fadeIn()), + exit = slideOutVertically().plus(fadeOut()), + label = "Animate show sent state buttons", + ) { + val wrappedPairButtonsUM = remember(this) { requireNotNull(pairButtonsUM) } + Row(modifier = Modifier.padding(bottom = 12.dp)) { + SecondaryButtonIconStart( + text = wrappedPairButtonsUM.leftText.resolveReference(), + iconResId = wrappedPairButtonsUM.leftIconResId!!, + onClick = wrappedPairButtonsUM.onLeftClick, + modifier = Modifier.weight(1f), + ) + SpacerW12() + SecondaryButtonIconStart( + text = wrappedPairButtonsUM.rightText.resolveReference(), + iconResId = wrappedPairButtonsUM.rightIconResId!!, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + wrappedPairButtonsUM.onRightClick() + }, + modifier = Modifier.weight(1f), + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/ButtonsUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/ButtonsUM.kt new file mode 100644 index 0000000000..9b4d716e8a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/ButtonsUM.kt @@ -0,0 +1,25 @@ +package com.tangem.features.send.v2.send.ui.state + +import androidx.annotation.DrawableRes +import com.tangem.core.ui.extensions.TextReference + +sealed class ButtonsUM { + + data class PrimaryButtonUM( + val text: TextReference, + val additionalText: TextReference? = null, + @DrawableRes val iconResId: Int? = null, + val isEnabled: Boolean, + val isHapticClick: Boolean = false, + val onClick: () -> Unit, + ) : ButtonsUM() + + data class SecondaryPairButtonsUM( + val leftText: TextReference, + @DrawableRes val leftIconResId: Int? = null, + val onLeftClick: () -> Unit, + val rightText: TextReference, + @DrawableRes val rightIconResId: Int? = null, + val onRightClick: () -> Unit, + ) : ButtonsUM() +} \ No newline at end of file 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 new file mode 100644 index 0000000000..c36bda9430 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/ui/state/SendUM.kt @@ -0,0 +1,15 @@ +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.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM + +internal data class SendUM( + val amountUM: AmountState, + 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/Notifications.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/Notifications.kt new file mode 100644 index 0000000000..4704183c22 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/Notifications.kt @@ -0,0 +1,48 @@ +package com.tangem.features.send.v2.subcomponents + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.res.TangemTheme +import kotlinx.collections.immutable.ImmutableList + +internal fun LazyListScope.notifications( + notifications: ImmutableList, + modifier: Modifier = Modifier, + hasPaddingAbove: Boolean = false, + isClickDisabled: Boolean = false, +) { + itemsIndexed( + items = notifications, + key = { _, item -> item::class.java }, + contentType = { _, item -> item::class.java }, + itemContent = { i, item -> + val topPadding = if (i == 0 && hasPaddingAbove) 0.dp else 12.dp + Notification( + config = item.config, + modifier = modifier + .padding(top = topPadding) + .animateItem(), + containerColor = when (item) { + is NotificationUM.Error.TokenExceedsBalance, + is NotificationUM.Warning.NetworkFeeUnreachable, + is NotificationUM.Warning.HighFeeError, + -> TangemTheme.colors.background.action + else -> TangemTheme.colors.button.disabled + }, + iconTint = when (item) { + is NotificationUM.Error.TokenExceedsBalance, + is NotificationUM.Warning, + -> null + is NotificationUM.Error -> TangemTheme.colors.icon.warning + is NotificationUM.Info -> TangemTheme.colors.icon.accent + }, + isEnabled = !isClickDisabled, + ) + }, + ) +} \ 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 new file mode 100644 index 0000000000..b616b506f7 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt @@ -0,0 +1,46 @@ +package com.tangem.features.send.v2.subcomponents.amount + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.amountScreen.models.AmountState +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.subcomponents.amount.SendAmountComponentParams.AmountBlockParams +import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +internal class SendAmountBlockComponent( + appComponentContext: AppComponentContext, + private val params: AmountBlockParams, + val onResult: (AmountState) -> Unit, + val onClick: () -> Unit, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SendAmountModel = getOrCreateModel(params = params) + + init { + model.uiState.onEach { + onResult(it) + }.launchIn(componentScope) + } + + fun updateState(amountUM: AmountState) = model.updateState(amountUM) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val isClickEnabled by params.blockClickEnableFlow.collectAsStateWithLifecycle() + + AmountBlock( + amountState = state, + isClickDisabled = !isClickEnabled, + isEditingDisabled = params.isPredefinedValues, + onClick = onClick, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt new file mode 100644 index 0000000000..f79c417846 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponent.kt @@ -0,0 +1,43 @@ +package com.tangem.features.send.v2.subcomponents.amount + +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.amountScreen.AmountScreenContent +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.common.SendNavigationModelCallback +import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams.AmountParams +import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel + +internal class SendAmountComponent( + appComponentContext: AppComponentContext, + private val params: AmountParams, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SendAmountModel = getOrCreateModel(params = params) + + fun updateState(amountUM: AmountState) = model.updateState(amountUM) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle() + + AmountScreenContent( + amountState = state, + isBalanceHidden = isBalanceHidden, + clickIntents = model, + modifier = Modifier.background(TangemTheme.colors.background.tertiary), + ) + } + + interface ModelCallback : SendNavigationModelCallback { + fun onAmountResult(amountUM: AmountState) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..0df4012c9d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountComponentParams.kt @@ -0,0 +1,44 @@ +package com.tangem.features.send.v2.subcomponents.amount + +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.subcomponents.amount.SendAmountComponent.ModelCallback +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +internal sealed class SendAmountComponentParams { + + abstract val state: AmountState + abstract val analyticsCategoryName: String + abstract val userWallet: UserWallet + abstract val appCurrency: AppCurrency + abstract val cryptoCurrencyStatus: CryptoCurrencyStatus + abstract val predefinedAmountValue: String? + + data class AmountParams( + override val state: AmountState, + override val analyticsCategoryName: String, + override val userWallet: UserWallet, + override val appCurrency: AppCurrency, + override val cryptoCurrencyStatus: CryptoCurrencyStatus, + override val predefinedAmountValue: String?, + val isEditMode: Boolean, + val callback: ModelCallback, + val currentRoute: Flow, + val isBalanceHidingFlow: StateFlow, + ) : SendAmountComponentParams() + + data class AmountBlockParams( + override val state: AmountState, + override val analyticsCategoryName: String, + override val userWallet: UserWallet, + override val appCurrency: AppCurrency, + override val cryptoCurrencyStatus: CryptoCurrencyStatus, + override val predefinedAmountValue: String?, + 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/SendAmountReduceTrigger.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountReduceTrigger.kt new file mode 100644 index 0000000000..901ee1f0df --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountReduceTrigger.kt @@ -0,0 +1,48 @@ +package com.tangem.features.send.v2.subcomponents.amount + +import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import java.math.BigDecimal +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Trigger for reducing amount from another component + */ +interface SendAmountReduceTrigger { + suspend fun triggerReduceBy(reduceBy: ReduceByData) + suspend fun triggerReduceTo(reduceTo: BigDecimal) + suspend fun triggerIgnoreReduce() +} + +/** + * Trigger for reducing amount from another component + */ +interface SendAmountReduceListener { + val reduceToTriggerFlow: Flow + val reduceByTriggerFlow: Flow + val ignoreReduceTriggerFlow: Flow +} + +@Singleton +internal class DefaultSendAmountReduceTrigger @Inject constructor() : + SendAmountReduceTrigger, + SendAmountReduceListener { + + override val reduceToTriggerFlow = MutableSharedFlow() + override val reduceByTriggerFlow = MutableSharedFlow() + override val ignoreReduceTriggerFlow = MutableSharedFlow() + + override suspend fun triggerReduceBy(reduceBy: ReduceByData) { + reduceByTriggerFlow.emit(reduceBy) + } + + override suspend fun triggerReduceTo(reduceTo: BigDecimal) { + reduceToTriggerFlow.emit(reduceTo) + } + + override suspend fun triggerIgnoreReduce() { + ignoreReduceTriggerFlow.emit(Unit) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/analytics/SendAmountAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/analytics/SendAmountAnalyticEvents.kt new file mode 100644 index 0000000000..97d65a88e9 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/analytics/SendAmountAnalyticEvents.kt @@ -0,0 +1,31 @@ +package com.tangem.features.send.v2.subcomponents.amount.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE + +internal sealed class SendAmountAnalyticEvents( + category: String, + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = category, event = event, params = params) { + + /** Selected currency */ + data class SelectedCurrency( + val categoryName: String, + val type: SelectedCurrencyType, + ) : SendAmountAnalyticEvents( + category = categoryName, + event = "Selected Currency", + params = mapOf(TYPE to type.value), + ) + + /** Max amount button clicked */ + data class MaxAmountButtonClicked( + val categoryName: String, + ) : SendAmountAnalyticEvents(category = categoryName, event = "Max Amount Taped") + + internal enum class SelectedCurrencyType(val value: String) { + Token("Token"), + AppCurrency("App Currency"), + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/di/SendAmountModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/di/SendAmountModule.kt new file mode 100644 index 0000000000..b486aeaeae --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/di/SendAmountModule.kt @@ -0,0 +1,23 @@ +package com.tangem.features.send.v2.subcomponents.amount.di + +import com.tangem.features.send.v2.subcomponents.amount.DefaultSendAmountReduceTrigger +import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceListener +import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceTrigger +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +internal interface SendAmountModule { + + @Singleton + @Binds + fun provideSendAmountReduceTrigger(impl: DefaultSendAmountReduceTrigger): SendAmountReduceTrigger + + @Singleton + @Binds + fun provideSendAmountReduceListener(impl: DefaultSendAmountReduceTrigger): SendAmountReduceListener +} \ 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 new file mode 100644 index 0000000000..ce05dce8b0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -0,0 +1,260 @@ +package com.tangem.features.send.v2.subcomponents.amount.model + +import androidx.compose.runtime.Stable +import com.tangem.common.ui.amountScreen.AmountScreenClickIntents +import com.tangem.common.ui.amountScreen.converters.* +import com.tangem.common.ui.amountScreen.converters.field.AmountFieldChangeTransformer +import com.tangem.common.ui.amountScreen.converters.field.AmountFieldSetMaxAmountTransformer +import com.tangem.common.ui.amountScreen.models.AmountParameters +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +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.impl.R +import com.tangem.features.send.v2.send.SendRoute +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 +import com.tangem.features.send.v2.subcomponents.amount.analytics.SendAmountAnalyticEvents +import com.tangem.features.send.v2.subcomponents.amount.analytics.SendAmountAnalyticEvents.SelectedCurrencyType +import com.tangem.features.send.v2.subcomponents.fee.SendFeeData +import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadTrigger +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.orZero +import com.tangem.utils.isNullOrZero +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class SendAmountModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, + private val sendAmountReduceListener: SendAmountReduceListener, + private val feeReloadTrigger: SendFeeReloadTrigger, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model(), AmountScreenClickIntents { + + private val params: SendAmountComponentParams = paramsContainer.require() + + private val _uiState = MutableStateFlow(params.state) + val uiState = _uiState.asStateFlow() + + private val analyticsCategoryName = params.analyticsCategoryName + private val userWallet = params.userWallet + private val cryptoCurrencyStatus = params.cryptoCurrencyStatus + + private var minAmountBoundary: EnterAmountBoundary? = null + private var maxAmountBoundary: EnterAmountBoundary = MaxEnterAmountConverter().convert(cryptoCurrencyStatus) + + init { + configAmountNavigation() + initMinBoundary() + subscribeOnAmountReduceByTriggerUpdates() + subscribeOnAmountReduceToTriggerUpdates() + subscribeOnAmountIgnoreReduceTriggerUpdates() + } + + private fun initMinBoundary() { + modelScope.launch { + minAmountBoundary = getMinimumTransactionAmountSyncUseCase( + userWalletId = userWallet.walletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ).getOrNull()?.let { + EnterAmountBoundary( + amount = it, + fiatRate = cryptoCurrencyStatus.value.fiatRate.orZero(), + ) + } + + initialState() + } + } + + private fun initialState() { + if (uiState.value is AmountState.Empty) { + _uiState.update { + AmountStateConverterV2( + clickIntents = this, + appCurrency = params.appCurrency, + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxEnterAmount = maxAmountBoundary, + iconStateConverter = CryptoCurrencyToIconStateConverter(), + ).convert( + AmountParameters( + title = stringReference(userWallet.name), + value = "", + ), + ) + } + params.predefinedAmountValue?.let(::onAmountValueChange) + } + } + + fun updateState(amountUM: AmountState) { + if (amountUM !is AmountState.Empty) { + _uiState.value = amountUM + } + } + + override fun onCurrencyChangeClick(isFiat: Boolean) { + _uiState.update(AmountCurrencyTransformer(cryptoCurrencyStatus, isFiat)) + } + + override fun onAmountValueChange(value: String) { + _uiState.update( + AmountFieldChangeTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxEnterAmount = maxAmountBoundary, + minimumTransactionAmount = minAmountBoundary, + value = value, + ), + ) + } + + override fun onMaxValueClick() { + val decimalCryptoValue = cryptoCurrencyStatus.value.amount + if (decimalCryptoValue.isNullOrZero()) return + + _uiState.update( + AmountFieldSetMaxAmountTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + maxAmount = maxAmountBoundary, + minAmount = minAmountBoundary, + ), + ) + analyticsEventHandler.send( + SendAmountAnalyticEvents.MaxAmountButtonClicked(categoryName = analyticsCategoryName), + ) + } + + override fun onAmountPasteTriggerDismiss() { + _uiState.update(AmountPastedTriggerDismissTransformer) + } + + override fun onAmountNext() { + (uiState.value as? AmountState.Data)?.amountTextField?.isFiatValue?.let { isFiatSelected -> + analyticsEventHandler.send( + SendAmountAnalyticEvents.SelectedCurrency( + categoryName = analyticsCategoryName, + type = if (isFiatSelected) { + SelectedCurrencyType.AppCurrency + } else { + SelectedCurrencyType.Token + }, + ), + ) + } + saveResult() + if ((params as? SendAmountComponentParams.AmountParams)?.isEditMode == true) { + router.pop() + } else { + router.push(SendRoute.Confirm) + } + } + + private fun subscribeOnAmountReduceToTriggerUpdates() { + sendAmountReduceListener.reduceToTriggerFlow + .onEach { reduceTo -> + _uiState.update( + AmountReduceToTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + minimumTransactionAmount = minAmountBoundary, + value = reduceTo, + ), + ) + feeReloadTrigger.triggerUpdate( + feeData = SendFeeData( + amount = (uiState.value as? AmountState.Data)?.amountTextField?.cryptoAmount?.value, + ), + ) + } + .launchIn(modelScope) + } + + private fun subscribeOnAmountReduceByTriggerUpdates() { + sendAmountReduceListener.reduceByTriggerFlow + .onEach { reduceByData -> + _uiState.update( + AmountReduceByTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + minimumTransactionAmount = minAmountBoundary, + value = reduceByData, + ), + ) + feeReloadTrigger.triggerUpdate( + feeData = SendFeeData( + amount = (uiState.value as? AmountState.Data)?.amountTextField?.cryptoAmount?.value, + ), + ) + } + .launchIn(modelScope) + } + + private fun subscribeOnAmountIgnoreReduceTriggerUpdates() { + sendAmountReduceListener.ignoreReduceTriggerFlow + .onEach { _uiState.update(AmountIgnoreReduceTransformer::transform) } + .launchIn(modelScope) + } + + private fun saveResult() { + val params = params as? SendAmountComponentParams.AmountParams ?: return + params.callback.onAmountResult(uiState.value) + } + + private fun configAmountNavigation() { + val params = params as? SendAmountComponentParams.AmountParams ?: return + combine( + flow = uiState, + flow2 = params.currentRoute, + transform = { state, route -> state to route }, + ).onEach { (state, route) -> + params.callback.onNavigationResult( + NavigationUM.Content( + title = resourceReference(R.string.send_amount_label), + subtitle = null, + backIconRes = R.drawable.ic_back_24, + backIconClick = { + if (!route.isEditMode) { + saveResult() + } + router.pop() + }, + primaryButton = ButtonsUM.PrimaryButtonUM( + text = if (route.isEditMode) { + resourceReference(R.string.common_continue) + } else { + resourceReference(R.string.common_next) + }, + isEnabled = state.isPrimaryButtonEnabled, + onClick = ::onAmountNext, + ), + prevButton = ButtonsUM.PrimaryButtonUM( + text = TextReference.EMPTY, + iconResId = R.drawable.ic_back_24, + isEnabled = true, + onClick = { + saveResult() + router.pop() + }, + ).takeIf { route.isEditMode.not() }, + secondaryPairButtonsUM = null, + ), + ) + }.launchIn(modelScope) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationAlertFactory.kt new file mode 100644 index 0000000000..ed46533724 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationAlertFactory.kt @@ -0,0 +1,28 @@ +package com.tangem.features.send.v2.subcomponents.destination + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.features.send.v2.impl.R +import javax.inject.Inject + +@ModelScoped +internal class SendDestinationAlertFactory @Inject constructor( + val messageSender: UiMessageSender, +) { + + fun getGenericErrorState(onFailedTxEmailClick: () -> Unit) { + messageSender.send( + DialogMessage( + title = resourceReference(id = R.string.send_alert_transaction_failed_title), + message = resourceReference(id = R.string.common_unknown_error), + firstAction = EventMessageAction( + title = resourceReference(R.string.common_support), + onClick = onFailedTxEmailClick, + ), + ), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..ff98000eed --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt @@ -0,0 +1,45 @@ +package com.tangem.features.send.v2.subcomponents.destination + +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.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 +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +internal class SendDestinationBlockComponent( + appComponentContext: AppComponentContext, + private val params: SendDestinationComponentParams.DestinationBlockParams, + val onClick: () -> Unit, + val onResult: (DestinationUM) -> Unit, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SendDestinationModel = getOrCreateModel(params = params) + + init { + model.uiState.onEach { + onResult(it) + }.launchIn(componentScope) + } + + fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val isClickEnabled by params.blockClickEnableFlow.collectAsStateWithLifecycle() + + DestinationBlock( + destinationUM = state, + isClickDisabled = !isClickEnabled, + isEditingDisabled = params.isPredefinedValues, + onClick = onClick, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponent.kt new file mode 100644 index 0000000000..f1b52451d8 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.send.v2.subcomponents.destination + +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.features.send.v2.common.SendNavigationModelCallback +import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel +import com.tangem.features.send.v2.subcomponents.destination.ui.SendDestinationContent +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM + +internal class SendDestinationComponent( + appComponentContext: AppComponentContext, + private val params: SendDestinationComponentParams.DestinationParams, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SendDestinationModel = getOrCreateModel(params = params) + + fun updateState(state: DestinationUM) = model.updateState(state) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle() + + SendDestinationContent(state = state, clickIntents = model, isBalanceHidden = isBalanceHidden) + } + + interface ModelCallback : SendNavigationModelCallback { + fun onDestinationResult(destinationUM: DestinationUM) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f4a69acb24 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt @@ -0,0 +1,39 @@ +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.subcomponents.destination.SendDestinationComponent.ModelCallback +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +internal sealed class SendDestinationComponentParams { + + abstract val state: DestinationUM + abstract val analyticsCategoryName: String + abstract val userWalletId: UserWalletId + abstract val cryptoCurrency: CryptoCurrency + + data class DestinationParams( + override val state: DestinationUM, + override val analyticsCategoryName: String, + override val cryptoCurrency: CryptoCurrency, + override val userWalletId: UserWalletId, + val isBalanceHidingFlow: StateFlow, + val currentRoute: Flow, + val callback: ModelCallback, + val isEditMode: Boolean, + ) : SendDestinationComponentParams() + + data class DestinationBlockParams( + override val state: DestinationUM, + override val analyticsCategoryName: String, + override val userWalletId: UserWalletId, + override val cryptoCurrency: CryptoCurrency, + val blockClickEnableFlow: StateFlow, + val predefinedAddressValue: String?, + val predefinedMemoValue: String?, + val isPredefinedValues: Boolean, + ) : SendDestinationComponentParams() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/analytics/EnterAddressSource.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/analytics/EnterAddressSource.kt new file mode 100644 index 0000000000..1c61574dfa --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/analytics/EnterAddressSource.kt @@ -0,0 +1,12 @@ +package com.tangem.features.send.v2.subcomponents.destination.analytics + +internal enum class EnterAddressSource { + QRCode, + PasteButton, + RecentAddress, + InputField, + ; + + val isPasted: Boolean + get() = this != InputField +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/analytics/SendDestinationAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/analytics/SendDestinationAnalyticEvents.kt new file mode 100644 index 0000000000..84c52b4907 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/analytics/SendDestinationAnalyticEvents.kt @@ -0,0 +1,37 @@ +package com.tangem.features.send.v2.subcomponents.destination.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE +import com.tangem.core.analytics.models.AnalyticsParam.Key.VALIDATION + +internal sealed class SendDestinationAnalyticEvents( + category: String, + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = category, event = event, params = params) { + + abstract val categoryName: String + + /** Address to send entered */ + data class AddressEntered( + override val categoryName: String, + val source: EnterAddressSource, + val isValid: Boolean, + ) : SendDestinationAnalyticEvents( + category = categoryName, + event = "Address Entered", + params = mapOf( + SOURCE to source.name, + VALIDATION to if (isValid) "Success" else "Fail", + ), + ) + + /** Qr Code button clicked */ + data class QrCodeButtonClicked( + override val categoryName: String, + ) : + SendDestinationAnalyticEvents( + category = categoryName, + event = "Button - QR Code", + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationClickIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationClickIntents.kt new file mode 100644 index 0000000000..73a1ce762b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationClickIntents.kt @@ -0,0 +1,12 @@ +package com.tangem.features.send.v2.subcomponents.destination.model + +import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource + +internal interface SendDestinationClickIntents { + + fun onRecipientAddressValueChange(value: String, type: EnterAddressSource) + + fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean = false) + + fun onQrCodeScanClick() +} \ 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 new file mode 100644 index 0000000000..14f2d7a777 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -0,0 +1,341 @@ +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 +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyUseCase +import com.tangem.domain.tokens.GetNetworkAddressesUseCase +import com.tangem.domain.tokens.model.CryptoCurrencyAddress +import com.tangem.domain.transaction.usecase.IsUtxoConsolidationAvailableUseCase +import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase +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.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.analytics.EnterAddressSource +import com.tangem.features.send.v2.subcomponents.destination.analytics.SendDestinationAnalyticEvents +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.* +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.coroutines.waitForDelay +import com.tangem.utils.transformer.update +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +@Suppress("LongParameterList", "LargeClass") +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, + private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase, + private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase, + private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase, + private val isUtxoConsolidationAvailableUseCase: IsUtxoConsolidationAvailableUseCase, + private val listenToQrScanningUseCase: ListenToQrScanningUseCase, + private val parseQrCodeUseCase: ParseQrCodeUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model(), SendDestinationClickIntents { + private val params: SendDestinationComponentParams = paramsContainer.require() + + private val _uiState = MutableStateFlow(params.state) + val uiState = _uiState.asStateFlow() + + private val analyticsCategoryName = params.analyticsCategoryName + private val cryptoCurrency = params.cryptoCurrency + private val userWalletId = params.userWalletId + + private val senderAddresses = MutableStateFlow>(emptyList()) + + private var validationJobHolder = JobHolder() + + init { + initSenderAddress() + configDestinationNavigation() + subscribeOnQRScannerResult() + initialState() + } + + private fun initSenderAddress() { + modelScope.launch { + senderAddresses.value = getNetworkAddressesUseCase.invokeSync(userWalletId, cryptoCurrency.network) + .filter { cryptoCurrency.id == it.cryptoCurrency.id } + } + senderAddresses.onEach { + getWalletsAndRecent() + }.launchIn(modelScope) + } + + private fun initialState() { + if ((uiState.value as? DestinationUM.Content)?.isInitialized == false) { + _uiState.update( + SendDestinationInitialStateTransformer( + cryptoCurrency = cryptoCurrency, + isInitialized = true, + ), + ) + val params = params as? SendDestinationComponentParams.DestinationBlockParams + if (params?.predefinedAddressValue != null) { + _uiState.update( + SendDestinationPredefinedStateTransformer( + address = params.predefinedAddressValue, + memo = params.predefinedMemoValue, + ), + ) + } + } + } + + fun updateState(destinationUM: DestinationUM) { + if (destinationUM is DestinationUM.Content && destinationUM.isInitialized) { + _uiState.value = destinationUM + } + } + + override fun onRecipientAddressValueChange(value: String, type: EnterAddressSource) { + _uiState.update( + SendDestinationAddressTransformer( + address = value, + isPasted = type.isPasted, + ), + ) + val memo = (uiState.value as? DestinationUM.Content)?.memoTextField?.value + validate(address = value, memo = memo, type) + } + + override fun onRecipientMemoValueChange(value: String, isValuePasted: Boolean) { + _uiState.update( + SendDestinationMemoTransformer( + memo = value, + isPasted = isValuePasted, + ), + ) + val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.value.orEmpty() + validate(address = address, memo = value) + } + + override fun onQrCodeScanClick() { + analyticsEventHandler.send(SendDestinationAnalyticEvents.QrCodeButtonClicked(analyticsCategoryName)) + router.push( + AppRoute.QrScanning(source = AppRoute.QrScanning.Source.Send(cryptoCurrency.network.name)), + ) + } + + private fun subscribeOnQRScannerResult() { + listenToQrScanningUseCase(SourceType.SEND) + .getOrElse { emptyFlow() } + .onEach(::onQrCodeScanned) + .launchIn(modelScope) + } + + private fun onQrCodeScanned(address: String) { + val parsedQrCode = parseQrCodeUseCase(address, cryptoCurrency).getOrNull() ?: return + _uiState.update( + SendDestinationPredefinedStateTransformer( + address = parsedQrCode.address, + memo = parsedQrCode.memo, + ), + ) + validate( + address = parsedQrCode.address, + memo = parsedQrCode.memo, + type = EnterAddressSource.QRCode, + ) + } + + private fun getWalletsAndRecent() { + combine( + flow = getWalletsUseCase().conflate().map { + waitForDelay(RECENT_LOAD_DELAY) { it.toAvailableWallets() } + }, + flow2 = getFixedTxHistoryItemsUseCase( + userWalletId = userWalletId, + currency = cryptoCurrency, + pageSize = RECENT_TX_SIZE, + ).getOrElse { flowOf(emptyList()) }.map { + waitForDelay(RECENT_LOAD_DELAY) { it } + }.conflate(), + ) { destinationWalletList, txHistoryList -> + val isUtxoConsolidationAvailable = isUtxoConsolidationAvailableUseCase.invokeSync( + userWalletId = userWalletId, + network = cryptoCurrency.network, + ) + + _uiState.update( + SendDestinationRecentListTransformer( + cryptoCurrency = cryptoCurrency, + senderAddress = senderAddresses.value.firstOrNull()?.address, + isUtxoConsolidationAvailable = isUtxoConsolidationAvailable, + destinationWalletList = destinationWalletList, + txHistoryList = txHistoryList, + ), + ) + }.launchIn(modelScope) + } + + private suspend fun List.toAvailableWallets(): List { + return coroutineScope { + val cryptoCurrencyNetwork = cryptoCurrency.network + + return@coroutineScope filterNot { it.isLocked } + .map { wallet -> + async { + val addresses = if (!wallet.isMultiCurrency) { + getCryptoCurrencyUseCase(wallet.walletId).getOrNull()?.let { + if (it.network.id == cryptoCurrencyNetwork.id) { + getNetworkAddressesUseCase.invokeSync(wallet.walletId, it.network) + } else { + null + } + } + } else { + getNetworkAddressesUseCase.invokeSync(wallet.walletId, cryptoCurrencyNetwork) + } + wallet to addresses + } + }.awaitAll() + .asSequence() + .mapNotNull { (wallet, addresses) -> + addresses?.map { (cryptoCurrency, address) -> + DestinationWalletUM( + name = wallet.name, + address = address, + cryptoCurrency = cryptoCurrency, + userWalletId = wallet.walletId, + ) + } + }.flatten() + .toList() + } + } + + private fun validate(address: String, memo: String?, type: EnterAddressSource? = null) { + modelScope.launch { + _uiState.update(SendDestinationValidationStartedTransformer) + + val addressValidationResult = validateWalletAddressUseCase( + userWalletId = userWalletId, + network = cryptoCurrency.network, + address = address, + senderAddresses = senderAddresses.value, + ) + val memoValidationResult = validateWalletMemoUseCase( + memo = memo.orEmpty(), + network = cryptoCurrency.network, + ) + + type?.let { + analyticsEventHandler.send( + SendDestinationAnalyticEvents.AddressEntered( + categoryName = analyticsCategoryName, + source = it, + isValid = addressValidationResult.isRight(), + ), + ) + } + _uiState.update(SendDestinationValidationResultTransformer(addressValidationResult, memoValidationResult)) + autoNextFromRecipient(type, addressValidationResult.isRight(), memoValidationResult.isRight()) + }.saveIn(validationJobHolder) + } + + private fun autoNextFromRecipient(type: EnterAddressSource?, isValidAddress: Boolean, isValidMemo: Boolean) { + val isRecent = type == EnterAddressSource.RecentAddress + if (isRecent && isValidAddress && isValidMemo) onNextClick() + } + + private fun saveResult() { + val params = params as? SendDestinationComponentParams.DestinationParams ?: return + 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( + flow = uiState, + flow2 = params.currentRoute, + transform = { state, route -> state to route }, + ).onEach { (state, route) -> + params.callback.onNavigationResult( + NavigationUM.Content( + title = resourceReference(R.string.send_recipient_label), + subtitle = null, + backIconRes = if (route.isEditMode) { + R.drawable.ic_back_24 + } else { + R.drawable.ic_close_24 + }, + backIconClick = { + if (route.isEditMode) { + router.pop() + } else { + analyticsEventHandler.send( + SendAnalyticEvents.CloseButtonClicked( + source = SendScreenSource.Address, + isFromSummary = false, + isValid = state.isPrimaryButtonEnabled, + ), + ) + appRouter.pop() + } + }, + additionalIconRes = R.drawable.ic_qrcode_scan_24, + additionalIconClick = ::onQrCodeScanClick, + primaryButton = ButtonsUM.PrimaryButtonUM( + text = if (route.isEditMode) { + resourceReference(R.string.common_continue) + } else { + resourceReference(R.string.common_next) + }, + isEnabled = state.isPrimaryButtonEnabled, + onClick = ::onNextClick, + ), + prevButton = null, + secondaryPairButtonsUM = null, + ), + ) + }.launchIn(modelScope) + } + + private companion object { + const val RECENT_TX_SIZE = 100 + const val RECENT_LOAD_DELAY = 500L + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientHistoryListConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientHistoryListConverter.kt new file mode 100644 index 0000000000..3c77cd1b51 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientHistoryListConverter.kt @@ -0,0 +1,92 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.converters + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.RECENT_DEFAULT_COUNT +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.RECENT_KEY_TAG +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.emptyListState +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList + +internal class SendRecipientHistoryListConverter( + private val cryptoCurrency: CryptoCurrency, +) : Converter, ImmutableList> { + + override fun convert(value: List): ImmutableList { + return value.filterRecipients(cryptoCurrency).ifEmpty { + emptyListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT) + } + } + + private fun List.filterRecipients(cryptoCurrency: CryptoCurrency) = this.filter { item -> + val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer + val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User + val isSingleAddress = if (item.isOutgoing) { + item.destinationType is TxHistoryItem.DestinationType.Single + } else { + item.sourceType is TxHistoryItem.SourceType.Single + } + val notZero = !item.amount.isZero() + isTransfer && isSingleAddress && isNotContract && item.isOutgoing && notZero + } + .take(RECENT_LIST_SIZE) + .mapIndexed { index, tx -> + DestinationRecipientListUM( + id = "${RECENT_KEY_TAG}$index", + title = tx.extractAddress(), + subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()), + timestamp = tx.extractTimestamp(), + subtitleEndOffset = cryptoCurrency.symbol.length, + subtitleIconRes = tx.extractIconRes(), + ) + }.toPersistentList() + + private fun TxHistoryItem.extractAddress(): TextReference = if (isOutgoing) { + when (val destination = destinationType) { + is TxHistoryItem.DestinationType.Multiple -> resourceReference( + R.string.transaction_history_multiple_addresses, + ) + is TxHistoryItem.DestinationType.Single -> stringReference(destination.addressType.address) + } + } else { + when (val source = sourceType) { + is TxHistoryItem.SourceType.Multiple -> resourceReference(R.string.transaction_history_multiple_addresses) + is TxHistoryItem.SourceType.Single -> stringReference(source.address) + } + } + + private fun TxHistoryItem.extractIconRes() = if (isOutgoing) { + R.drawable.ic_arrow_up_24 + } else { + R.drawable.ic_arrow_down_24 + } + + private fun TxHistoryItem.getAmount(cryptoCurrency: CryptoCurrency): String { + return amount.format { crypto(cryptoCurrency) } + } + + private fun TxHistoryItem.extractTimestamp(): TextReference { + val date = timestampInMillis.toDateFormatWithTodayYesterday( + formatter = DateTimeFormatters.dateDDMMYYYY, + ) + val time = timestampInMillis.toTimeFormat() + return TextReference.Res(R.string.send_date_format, wrappedList(date, time)) + } + + companion object { + private const val RECENT_LIST_SIZE = 10 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt new file mode 100644 index 0000000000..6837943a94 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/converters/SendRecipientWalletListConverter.kt @@ -0,0 +1,63 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.converters + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.WALLET_DEFAULT_COUNT +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.WALLET_KEY_TAG +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.emptyListState +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList + +internal class SendRecipientWalletListConverter( + private val senderAddress: String?, + private val isUtxoConsolidationAvailable: Boolean, +) : + Converter, PersistentList> { + override fun convert(value: List): PersistentList { + return value.filterWallets().ifEmpty { + emptyListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT) + } + } + + private fun List.filterWallets(): PersistentList { + var walletsCounter = 0 + + return this.filterNotNull() + .filter { + val isCoin = it.cryptoCurrency is CryptoCurrency.Coin + val isNotSameAddress = it.address != senderAddress + val isNotBlankAddress = it.address.isNotBlank() + + isNotBlankAddress && isCoin && (isNotSameAddress || isUtxoConsolidationAvailable) + } + .groupBy { item -> item.name } + .values.map { wallets -> + val groupedByWallet = wallets.groupBy { it.userWalletId } + var i = 0 + groupedByWallet + .flatMap { item -> + item.value.map { wallet -> + val name = if (groupedByWallet.size > 1) { + "${wallet.name} ${++i}" + } else { + wallet.name + } + + DestinationRecipientListUM( + id = "${WALLET_KEY_TAG}${walletsCounter++}", + title = stringReference(wallet.address), + subtitle = stringReference(name), + address = wallet.address, + userWalletId = wallet.userWalletId, + network = wallet.cryptoCurrency.network, + ) + } + } + } + .flatten() + .toPersistentList() + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/RecentListUtils.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/RecentListUtils.kt new file mode 100644 index 0000000000..0083e68d7d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/RecentListUtils.kt @@ -0,0 +1,32 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM +import kotlinx.collections.immutable.toPersistentList + +internal const val WALLET_DEFAULT_COUNT = 1 +internal const val RECENT_DEFAULT_COUNT = 3 +internal const val WALLET_KEY_TAG = "wallet" +internal const val RECENT_KEY_TAG = "recent" + +internal fun loadingListState(tag: String, count: Int) = buildList { + repeat(count) { + add( + DestinationRecipientListUM( + id = "$tag$it", + isLoading = true, + ), + ) + } +}.toPersistentList() + +internal fun emptyListState(tag: String, count: Int) = buildList { + repeat(count) { + add( + DestinationRecipientListUM( + id = "$tag$it", + isLoading = false, + isVisible = false, + ), + ) + } +}.toPersistentList() \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationAddressTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationAddressTransformer.kt new file mode 100644 index 0000000000..e717f65218 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationAddressTransformer.kt @@ -0,0 +1,18 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.utils.transformer.Transformer + +internal class SendDestinationAddressTransformer( + private val address: String, + private val isPasted: Boolean, +) : Transformer { + + override fun transform(prevState: DestinationUM): DestinationUM { + val state = prevState as? DestinationUM.Content ?: return prevState + + return state.copy( + addressTextField = state.addressTextField.copy(value = address, isValuePasted = isPasted), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt new file mode 100644 index 0000000000..8a1be0fbc2 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationInitialStateTransformer.kt @@ -0,0 +1,59 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.utils.transformer.Transformer + +internal class SendDestinationInitialStateTransformer( + val cryptoCurrency: CryptoCurrency, + val isInitialized: Boolean = false, +) : Transformer { + override fun transform(prevState: DestinationUM): DestinationUM { + val memoType = when (cryptoCurrency.network.transactionExtrasType) { + Network.TransactionExtrasType.NONE -> null + Network.TransactionExtrasType.MEMO -> R.string.send_extras_hint_memo + Network.TransactionExtrasType.DESTINATION_TAG -> R.string.send_destination_tag_field + } + return DestinationUM.Content( + isPrimaryButtonEnabled = false, + isInitialized = isInitialized, + addressTextField = DestinationTextFieldUM.RecipientAddress( + value = "", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Text, + ), + error = null, + placeholder = resourceReference(R.string.send_enter_address_field), + label = resourceReference(R.string.send_recipient), + isValuePasted = false, + ), + memoTextField = memoType?.let { + DestinationTextFieldUM.RecipientMemo( + value = "", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Text, + ), + placeholder = resourceReference(R.string.send_optional_field), + label = resourceReference(memoType), + error = resourceReference(R.string.send_memo_destination_tag_error), + disabledText = resourceReference(R.string.send_additional_field_already_included), + isEnabled = true, + isValuePasted = false, + ) + }, + wallets = loadingListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT), + recent = loadingListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT), + networkName = cryptoCurrency.network.name, + isValidating = false, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationMemoTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationMemoTransformer.kt new file mode 100644 index 0000000000..7a7883fcd8 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationMemoTransformer.kt @@ -0,0 +1,16 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.utils.transformer.Transformer + +internal class SendDestinationMemoTransformer( + private val memo: String, + private val isPasted: Boolean, +) : Transformer { + override fun transform(prevState: DestinationUM): DestinationUM { + val state = prevState as? DestinationUM.Content ?: return prevState + return state.copy( + memoTextField = state.memoTextField?.copy(value = memo, isValuePasted = isPasted), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationPredefinedStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationPredefinedStateTransformer.kt new file mode 100644 index 0000000000..dc7532f5cf --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationPredefinedStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.utils.transformer.Transformer + +internal class SendDestinationPredefinedStateTransformer( + private val address: String, + private val memo: String?, +) : Transformer { + + override fun transform(prevState: DestinationUM): DestinationUM { + val state = prevState as? DestinationUM.Content ?: return prevState + + return state.copy( + addressTextField = state.addressTextField.copy(value = address, isValuePasted = false), + memoTextField = memo?.let { state.memoTextField?.copy(value = it, isValuePasted = false) }, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt new file mode 100644 index 0000000000..8f670cab2d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationRecentListTransformer.kt @@ -0,0 +1,31 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientHistoryListConverter +import com.tangem.features.send.v2.subcomponents.destination.model.converters.SendRecipientWalletListConverter +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationWalletUM +import com.tangem.utils.transformer.Transformer + +internal class SendDestinationRecentListTransformer( + private val senderAddress: String?, + private val cryptoCurrency: CryptoCurrency, + private val isUtxoConsolidationAvailable: Boolean, + private val destinationWalletList: List, + private val txHistoryList: List, +) : Transformer { + override fun transform(prevState: DestinationUM): DestinationUM { + val state = prevState as? DestinationUM.Content ?: return prevState + + return state.copy( + wallets = SendRecipientWalletListConverter( + senderAddress = senderAddress, + isUtxoConsolidationAvailable = isUtxoConsolidationAvailable, + ).convert(destinationWalletList), + recent = SendRecipientHistoryListConverter( + cryptoCurrency = cryptoCurrency, + ).convert(txHistoryList), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformer.kt new file mode 100644 index 0000000000..a67c26d520 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformer.kt @@ -0,0 +1,60 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import arrow.core.Either +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.error.AddressValidationResult +import com.tangem.domain.transaction.error.ValidateMemoError +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toPersistentList + +internal class SendDestinationValidationResultTransformer( + private val addressValidationResult: AddressValidationResult, + private val memoValidationResult: Either, +) : Transformer { + override fun transform(prevState: DestinationUM): DestinationUM { + val state = prevState as? DestinationUM.Content ?: return prevState + + val isValidAddress = addressValidationResult.isRight() + val isValidMemo = memoValidationResult.isRight() + + val addressErrorText = addressValidationResult.mapLeft { + when (it) { + is AddressValidation.Error.DataError, + AddressValidation.Error.InvalidAddress, + -> R.string.send_recipient_address_error + AddressValidation.Error.AddressInWallet -> R.string.send_error_address_same_as_wallet + } + }.leftOrNull() + + val shouldDisableMemo = shouldDisableMemo() + + return state.copy( + isValidating = false, + isPrimaryButtonEnabled = isValidAddress && isValidMemo, + addressTextField = state.addressTextField.copy( + error = addressErrorText?.let(::resourceReference), + isError = state.addressTextField.value.isNotEmpty() && !isValidAddress, + ), + memoTextField = state.memoTextField?.copy( + value = state.memoTextField.value.takeIf { !shouldDisableMemo }.orEmpty(), + isError = state.memoTextField.value.isNotEmpty() && !isValidMemo, + isEnabled = !shouldDisableMemo, + ), + recent = state.recent.map { recent -> + recent.copy(isVisible = !isValidAddress && (recent.isLoading || recent.title != TextReference.EMPTY)) + }.toPersistentList(), + wallets = state.wallets.map { wallet -> + wallet.copy(isVisible = !isValidAddress && (wallet.isLoading || wallet.title != TextReference.EMPTY)) + }.toPersistentList(), + ) + } + + /** Ripple X-Address contains memo, so memo field is unnecessary */ + private fun shouldDisableMemo(): Boolean { + return addressValidationResult.isRight { it == AddressValidation.Success.ValidXAddress } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformer.kt new file mode 100644 index 0000000000..bc2354f57f --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationStartedTransformer.kt @@ -0,0 +1,12 @@ +package com.tangem.features.send.v2.subcomponents.destination.model.transformers + +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.utils.transformer.Transformer + +internal object SendDestinationValidationStartedTransformer : Transformer { + override fun transform(prevState: DestinationUM): DestinationUM { + val state = prevState as? DestinationUM.Content ?: return prevState + + return state.copy(isValidating = true) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt new file mode 100644 index 0000000000..9fe2722f12 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/DestinationBlock.kt @@ -0,0 +1,87 @@ +package com.tangem.features.send.v2.subcomponents.destination.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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 com.tangem.core.ui.components.icons.identicon.IdentIcon +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM + +@Composable +internal fun DestinationBlock( + destinationUM: DestinationUM, + isClickDisabled: Boolean, + isEditingDisabled: Boolean, + onClick: () -> Unit, +) { + if (destinationUM !is DestinationUM.Content) return + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick) + .padding(TangemTheme.dimens.spacing12), + ) { + AddressBlock(destinationUM.addressTextField) + MemoBlock(destinationUM.memoTextField) + } +} + +@Composable +private fun AddressBlock(address: DestinationTextFieldUM.RecipientAddress) { + Text( + text = address.label.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) { + IdentIcon( + address = address.value, + modifier = Modifier + .size(TangemTheme.dimens.size36) + .clip(RoundedCornerShape(TangemTheme.dimens.radius18)) + .background(TangemTheme.colors.background.tertiary), + ) + Text( + text = address.value, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } +} + +@Composable +private fun MemoBlock(memo: DestinationTextFieldUM.RecipientMemo?) { + if (memo != null && memo.value.isNotBlank()) { + HorizontalDivider( + color = TangemTheme.colors.icon.inactive, + modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12), + ) + Text( + text = memo.label.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + Text( + text = memo.value, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt new file mode 100644 index 0000000000..458fa9d397 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/ListItemWithIcon.kt @@ -0,0 +1,245 @@ +package com.tangem.features.send.v2.subcomponents.destination.ui + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +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.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.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.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.components.icons.identicon.IdentIcon +import com.tangem.core.ui.extensions.rememberHapticFeedback +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.send.v2.impl.R + +/** + * Row item with title and subtitle + * + * @param title title + * @param subtitle subtitle + * @param onClick click listener + * @param modifier modifier + * @param info info + * @param subtitleEndOffset offset for subtitle ellipsis + * @param subtitleIconRes icon + */ +@Composable +fun ListItemWithIcon( + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + info: String? = null, + subtitleEndOffset: Int = 0, + @DrawableRes subtitleIconRes: Int? = null, + isLoading: Boolean = false, +) { + AnimatedContent( + targetState = isLoading, + label = "Recent List Content Animation", + transitionSpec = { fadeIn().togetherWith(fadeOut()) }, + ) { isLoadingState -> + if (isLoadingState) { + ListItemLoading(modifier = modifier) + } else { + ListItemWithIcon( + title = title, + subtitle = subtitle, + onClick = onClick, + info = info, + subtitleEndOffset = subtitleEndOffset, + subtitleIconRes = subtitleIconRes, + modifier = modifier, + ) + } + } +} + +@Composable +private fun ListItemWithIcon( + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + info: String? = null, + subtitleEndOffset: Int = 0, + @DrawableRes subtitleIconRes: Int? = null, +) { + val hapticFeedback = rememberHapticFeedback(state = title, onAction = onClick) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable { hapticFeedback() } + .padding(horizontal = 12.dp), + ) { + IdentIcon( + address = title, + modifier = Modifier + .padding(vertical = 8.dp) + .size(40.dp) + .clip(RoundedCornerShape(20.dp)), + ) + Column( + modifier = Modifier + .height(36.dp) + .padding(start = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + EllipsisText( + text = title, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Justify, + ellipsis = TextEllipsis.Middle, + modifier = Modifier, + ) + Row { + if (subtitleIconRes != null) { + Icon( + painter = painterResource(id = subtitleIconRes), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .padding(end = 2.dp) + .size(16.dp) + .background(TangemTheme.colors.background.tertiary, CircleShape) + .padding(2.dp), + ) + } + val (text, offset) = remember(subtitle, info) { + if (info != null) { + val suffix = ", $info" + subtitle + suffix to suffix.length + subtitleEndOffset + } else { + subtitle to 0 + } + } + EllipsisText( + text = text, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset), + ) + } + } + } +} + +@Composable +private fun ListItemLoading(modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + ) { + CircleShimmer( + modifier = Modifier + .padding(vertical = 8.dp) + .size(40.dp), + ) + Column( + modifier = Modifier + .height(36.dp) + .padding(start = 12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + RectangleShimmer( + radius = 3.dp, + modifier = Modifier.size( + width = 70.dp, + height = 12.dp, + ), + ) + RectangleShimmer( + radius = 3.dp, + modifier = Modifier.size( + width = 52.dp, + height = 12.dp, + ), + ) + } + } +} + +// region preview +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ListItemWithIconPreview( + @PreviewParameter(ListItemWithIconPreviewProvider::class) config: ListItemWithIconPreviewConfig, +) { + TangemThemePreview { + ListItemWithIcon( + title = config.title, + subtitle = config.subtitle, + subtitleEndOffset = config.subtitleEndOffset, + subtitleIconRes = config.iconRes, + onClick = {}, + isLoading = config.isLoading, + ) + } +} + +private data class ListItemWithIconPreviewConfig( + val title: String, + val subtitle: String, + val info: String? = null, + val subtitleEndOffset: Int = 0, + val iconRes: Int? = null, + val isLoading: Boolean = false, +) + +private class ListItemWithIconPreviewProvider : CollectionPreviewParameterProvider( + collection = listOf( + ListItemWithIconPreviewConfig( + title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE", + subtitle = "0.000000000000000000000000000000 BTC", + info = "0.0.0000 at 00:00", + subtitleEndOffset = "BTC".length, + iconRes = R.drawable.ic_arrow_down_24, + ), + ListItemWithIconPreviewConfig( + title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE", + subtitle = "1 BTC", + info = "0.0.0000 at 00:00", + subtitleEndOffset = "BTC".length, + iconRes = R.drawable.ic_arrow_down_24, + ), + ListItemWithIconPreviewConfig( + title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE", + subtitle = "Wallet", + ), + ListItemWithIconPreviewConfig( + title = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE", + subtitle = "0.000000000000000000000000000000 BTC", + info = "0.0.0000 at 00:00", + subtitleEndOffset = "BTC".length, + iconRes = R.drawable.ic_arrow_down_24, + isLoading = true, + ), + ), +) +//endregion \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt new file mode 100644 index 0000000000..967f088d14 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/SendDestinationContent.kt @@ -0,0 +1,248 @@ +package com.tangem.features.send.v2.subcomponents.destination.ui + +import androidx.annotation.StringRes +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.containers.FooterContainer +import com.tangem.core.ui.components.inputrow.InputRowRecipient +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource +import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationClickIntents +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationRecipientListUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationTextFieldUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import kotlinx.collections.immutable.ImmutableList + +private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY" +private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY" + +@Composable +internal fun SendDestinationContent( + state: DestinationUM, + clickIntents: SendDestinationClickIntents, + isBalanceHidden: Boolean, +) { + if (state !is DestinationUM.Content) return + val recipients = state.recent + val wallets = state.wallets + val memoField = state.memoTextField + val address = state.addressTextField + val isValidating by remember(state.isValidating) { derivedStateOf { state.isValidating } } + val isError by remember(address.isError) { derivedStateOf { address.isError } } + LazyColumn( + modifier = Modifier // Do not put fillMaxSize() in here + .background(TangemTheme.colors.background.tertiary) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + addressItem( + address = address, + networkName = state.networkName, + isError = isError, + isValidating = isValidating, + onAddressChange = clickIntents::onRecipientAddressValueChange, + ) + memoField( + memoField = memoField, + onMemoChange = clickIntents::onRecipientMemoValueChange, + ) + listHeaderItem( + titleRes = R.string.send_recipient_wallets_title, + isVisible = wallets.isNotEmpty() && wallets.first().isVisible, + isFirst = true, + ) + listItem( + list = wallets, + clickIntents = clickIntents, + isLast = recipients.any { !it.isVisible }, + isBalanceHidden = isBalanceHidden, + ) + listHeaderItem( + titleRes = R.string.send_recent_transactions, + isVisible = recipients.isNotEmpty() && recipients.first().isVisible, + isFirst = wallets.any { !it.isVisible }, + ) + listItem( + list = recipients, + clickIntents = clickIntents, + isLast = true, + isBalanceHidden = isBalanceHidden, + ) + } +} + +private fun LazyListScope.addressItem( + address: DestinationTextFieldUM.RecipientAddress, + networkName: String, + isError: Boolean, + isValidating: Boolean, + onAddressChange: (String, EnterAddressSource) -> Unit, +) { + item(key = ADDRESS_FIELD_KEY) { + FooterContainer( + footer = resourceReference(R.string.send_recipient_address_footer, wrappedList(networkName)), + ) { + InputRowRecipient( + value = address.value, + title = address.label, + placeholder = address.placeholder, + onValueChange = { onAddressChange(it, EnterAddressSource.InputField) }, + onPasteClick = { onAddressChange(it, EnterAddressSource.PasteButton) }, + isError = isError, + isLoading = isValidating, + error = address.error, + isValuePasted = address.isValuePasted, + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) + } + } +} + +private fun LazyListScope.memoField( + memoField: DestinationTextFieldUM.RecipientMemo?, + onMemoChange: (String, Boolean) -> Unit, +) { + if (memoField != null) { + item(key = MEMO_FIELD_KEY) { + val placeholder = if (memoField.isEnabled) memoField.placeholder else memoField.disabledText + TextFieldWithPaste( + value = memoField.value, + label = memoField.label, + placeholder = placeholder, + footer = resourceReference(R.string.send_recipient_memo_footer), + onValueChange = { onMemoChange(it, false) }, + onPasteClick = { onMemoChange(it, true) }, + modifier = Modifier.padding(top = 20.dp), + labelStyle = TangemTheme.typography.subtitle2, + isError = memoField.isError, + error = memoField.error, + isReadOnly = !memoField.isEnabled, + isValuePasted = memoField.isValuePasted, + ) + } + } +} + +private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Boolean, isFirst: Boolean) { + item(key = titleRes) { + AnimateRecentAppearance(isVisible) { + val (topPadding, paddingFromTop) = if (isFirst) { + 20.dp to 12.dp + } else { + 0.dp to 8.dp + } + val topRadius = if (isFirst) 16.dp else 0.dp + Text( + text = stringResourceSafe(titleRes), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .fillMaxWidth() + .padding(top = topPadding) + .clip( + RoundedCornerShape( + topEnd = topRadius, + topStart = topRadius, + ), + ) + .background(TangemTheme.colors.background.action) + .padding( + top = paddingFromTop, + bottom = 12.dp, + start = 12.dp, + end = 12.dp, + ), + ) + } + } +} + +private fun LazyListScope.listItem( + list: ImmutableList, + clickIntents: SendDestinationClickIntents, + isLast: Boolean, + isBalanceHidden: Boolean, +) { + items( + count = list.size, + key = { list[it].id }, + contentType = { list[it]::class.java }, + ) { index -> + val item = list[index] + val title = item.title.resolveReference() + + if (item.isVisible) { + ListItemWithIcon( + title = title, + subtitle = item.subtitle.orMaskWithStars(isBalanceHidden).resolveReference(), + info = item.timestamp?.resolveReference(), + subtitleEndOffset = item.subtitleEndOffset, + subtitleIconRes = item.subtitleIconRes, + onClick = { + clickIntents.onRecipientAddressValueChange( + title, + EnterAddressSource.RecentAddress, + ) + }, + isLoading = item.isLoading, + modifier = Modifier + .animateItem() + .then( + if (isLast && index == list.lastIndex) { + Modifier + .clip( + shape = RoundedCornerShape( + bottomStart = 16.dp, + bottomEnd = 16.dp, + ), + ) + } else { + Modifier + }, + ) + .background(TangemTheme.colors.background.action), + ) + } + } +} + +@Composable +private fun AnimateRecentAppearance(isVisible: Boolean, content: @Composable () -> Unit) { + AnimatedContent( + targetState = isVisible, + label = "Item Appearance Animation", + transitionSpec = { + (slideInHorizontally() + fadeIn()) + .togetherWith(slideOutVertically() + fadeOut()) + }, + ) { + if (it) { + content() + } else { + Box(modifier = Modifier.fillMaxWidth()) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt new file mode 100644 index 0000000000..162ab6bf7b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/TextFieldWithPaste.kt @@ -0,0 +1,103 @@ +package com.tangem.features.send.v2.subcomponents.destination.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterEnd +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.fields.SimpleTextField +import com.tangem.core.ui.components.inputrow.inner.CrossIcon +import com.tangem.core.ui.components.inputrow.inner.PasteButton +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.components.containers.FooterContainer + +@Composable +internal fun TextFieldWithPaste( + value: String, + placeholder: TextReference, + label: TextReference, + onValueChange: (String) -> Unit, + onPasteClick: (String) -> Unit, + modifier: Modifier = Modifier, + footer: TextReference? = null, + labelStyle: TextStyle = TangemTheme.typography.body2, + error: TextReference? = null, + isError: Boolean = false, + isReadOnly: Boolean = false, + isValuePasted: Boolean = false, +) { + val (title, color) = when { + isError && error != null -> error to TangemTheme.colors.text.warning + isReadOnly -> label to TangemTheme.colors.text.tertiary + else -> label to TangemTheme.colors.text.secondary + } + val placeholderColor = if (isReadOnly) TangemTheme.colors.text.tertiary else TangemTheme.colors.text.disabled + FooterContainer(modifier, footer) { + Box( + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding(end = 12.dp), + ) { + Row { + Column( + modifier = Modifier + .weight(1f) + .padding(12.dp), + ) { + Text( + text = title.resolveReference(), + style = labelStyle, + color = color, + ) + SimpleTextField( + value = value, + placeholder = placeholder, + placeholderColor = placeholderColor, + onValueChange = onValueChange, + readOnly = isReadOnly, + isValuePasted = isValuePasted, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + AnimatedVisibility( + visible = !isReadOnly, + label = "Animate read only status change", + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(CenterVertically), + ) { + CrossIcon( + onClick = onPasteClick, + ) + } + } + AnimatedVisibility( + visible = !isReadOnly, + label = "Animate read only status change", + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(CenterEnd), + ) { + PasteButton( + isPasteButtonVisible = value.isBlank(), + onClick = onPasteClick, + ) + } + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationRecipientListUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationRecipientListUM.kt new file mode 100644 index 0000000000..679928b142 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationRecipientListUM.kt @@ -0,0 +1,22 @@ +package com.tangem.features.send.v2.subcomponents.destination.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWalletId + +@Immutable +internal data class DestinationRecipientListUM( + val id: String, + val title: TextReference = TextReference.Companion.EMPTY, + val subtitle: TextReference = TextReference.Companion.EMPTY, + val timestamp: TextReference? = null, + val subtitleEndOffset: Int = 0, + @DrawableRes val subtitleIconRes: Int? = null, + val isVisible: Boolean = true, + val isLoading: Boolean = false, + val userWalletId: UserWalletId? = null, + val network: Network? = null, + val address: String? = null, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationTextFieldUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationTextFieldUM.kt new file mode 100644 index 0000000000..ccc6266ae2 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationTextFieldUM.kt @@ -0,0 +1,37 @@ +package com.tangem.features.send.v2.subcomponents.destination.ui.state + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal sealed class DestinationTextFieldUM { + + /** Current value */ + abstract val value: String + + /** Keyboard options */ + abstract val keyboardOptions: KeyboardOptions + + data class RecipientAddress( + override val value: String, + override val keyboardOptions: KeyboardOptions, + val placeholder: TextReference, + val label: TextReference, + val isError: Boolean = false, + val error: TextReference? = null, + val isValuePasted: Boolean, + ) : DestinationTextFieldUM() + + data class RecipientMemo( + override val value: String, + override val keyboardOptions: KeyboardOptions, + val placeholder: TextReference, + val label: TextReference, + val isError: Boolean = false, + val error: TextReference? = null, + val disabledText: TextReference, + val isEnabled: Boolean, + val isValuePasted: Boolean, + ) : DestinationTextFieldUM() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationUM.kt new file mode 100644 index 0000000000..06935a8368 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationUM.kt @@ -0,0 +1,25 @@ +package com.tangem.features.send.v2.subcomponents.destination.ui.state + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed class DestinationUM { + + abstract val isPrimaryButtonEnabled: Boolean + + data class Content( + override val isPrimaryButtonEnabled: Boolean, + val addressTextField: DestinationTextFieldUM.RecipientAddress, + val memoTextField: DestinationTextFieldUM.RecipientMemo?, + val recent: ImmutableList, + val wallets: ImmutableList, + val networkName: String, + val isValidating: Boolean = false, + val isInitialized: Boolean = false, + ) : DestinationUM() + + data class Empty( + override val isPrimaryButtonEnabled: Boolean = false, + ) : DestinationUM() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationWalletUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationWalletUM.kt new file mode 100644 index 0000000000..cc7629e559 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/ui/state/DestinationWalletUM.kt @@ -0,0 +1,21 @@ +package com.tangem.features.send.v2.subcomponents.destination.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Available wallet to send + * + * @property name wallet name + * @property userWalletId wallet id + * @property address blockchain address + * @property cryptoCurrency selected crypto currency + */ +@Immutable +data class DestinationWalletUM( + val name: String, + val userWalletId: UserWalletId, + val address: String, + val cryptoCurrency: CryptoCurrency, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeBlockComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeBlockComponent.kt new file mode 100644 index 0000000000..4190c4a79d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeBlockComponent.kt @@ -0,0 +1,42 @@ +package com.tangem.features.send.v2.subcomponents.fee + +import androidx.compose.runtime.Composable +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.features.send.v2.subcomponents.fee.ui.FeeBlock +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +internal class SendFeeBlockComponent( + appComponentContext: AppComponentContext, + private val params: SendFeeComponentParams.FeeBlockParams, + val onResult: (FeeUM) -> Unit, + val onClick: () -> Unit, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SendFeeModel = getOrCreateModel(params = params) + + init { + model.uiState.onEach { + onResult(it) + }.launchIn(componentScope) + } + + fun updateState(state: FeeUM) = model.updateState(state) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + val isClickEnabled = params.blockClickEnableFlow.collectAsStateWithLifecycle() + FeeBlock( + feeUM = state.value, + isClickEnabled = isClickEnabled.value, + onClick = onClick, + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..5b7cdce04a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponent.kt @@ -0,0 +1,31 @@ +package com.tangem.features.send.v2.subcomponents.fee + +import androidx.compose.runtime.Composable +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.features.send.v2.common.SendNavigationModelCallback +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeModel +import com.tangem.features.send.v2.subcomponents.fee.ui.SendFeeContent +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM + +internal class SendFeeComponent( + appComponentContext: AppComponentContext, + private val params: SendFeeComponentParams.FeeParams, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SendFeeModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state = model.uiState.collectAsStateWithLifecycle() + + SendFeeContent(state = state.value, clickIntents = model) + } + + interface ModelCallback : SendNavigationModelCallback { + fun onFeeResult(feeUM: FeeUM) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..82c9f88081 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeComponentParams.kt @@ -0,0 +1,47 @@ +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.subcomponents.fee.ui.state.FeeUM +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import java.math.BigDecimal + +internal sealed class SendFeeComponentParams { + + abstract val state: FeeUM + abstract val analyticsCategoryName: String + abstract val userWallet: UserWallet + abstract val cryptoCurrencyStatus: CryptoCurrencyStatus + abstract val feeCryptoCurrencyStatus: CryptoCurrencyStatus + abstract val appCurrency: AppCurrency + abstract val sendAmount: BigDecimal + abstract val destinationAddress: String + + data class FeeParams( + override val state: FeeUM, + override val analyticsCategoryName: String, + override val userWallet: UserWallet, + override val cryptoCurrencyStatus: CryptoCurrencyStatus, + override val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + override val appCurrency: AppCurrency, + override val sendAmount: BigDecimal, + override val destinationAddress: String, + val currentRoute: Flow, + val callback: SendFeeComponent.ModelCallback, + ) : SendFeeComponentParams() + + data class FeeBlockParams( + override val state: FeeUM, + override val analyticsCategoryName: String, + override val userWallet: UserWallet, + override val cryptoCurrencyStatus: CryptoCurrencyStatus, + override val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + override val appCurrency: AppCurrency, + override val sendAmount: BigDecimal, + override val destinationAddress: String, + val blockClickEnableFlow: StateFlow, + ) : SendFeeComponentParams() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeData.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeData.kt new file mode 100644 index 0000000000..7db1e3d289 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeData.kt @@ -0,0 +1,8 @@ +package com.tangem.features.send.v2.subcomponents.fee + +import java.math.BigDecimal + +data class SendFeeData( + val amount: BigDecimal?, + val destinationAddress: String? = null, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeReloadTrigger.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeReloadTrigger.kt new file mode 100644 index 0000000000..369baf924e --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeReloadTrigger.kt @@ -0,0 +1,66 @@ +package com.tangem.features.send.v2.subcomponents.fee + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +interface SendFeeReloadTrigger { + + /** Trigger fee update */ + suspend fun triggerUpdate(feeData: SendFeeData) +} + +interface SendFeeReloadListener { + /** Flow triggers fee reload */ + val reloadTriggerFlow: Flow +} + +interface SendFeeCheckReloadTrigger { + /** Trigger return callback with check result */ + suspend fun callbackCheckResult(isSuccess: Boolean) + + /** Trigger fee check reload */ + suspend fun triggerCheckUpdate() +} + +interface SendFeeCheckReloadListener { + /** + * Flow triggers fee check reload before transaction. + * Usually after significant time after fee was updated last time + */ + val checkReloadTriggerFlow: Flow + + /** Flow return result of fee check update */ + val checkReloadResultFlow: Flow +} + +@Singleton +internal class DefaultSendFeeReloadTrigger @Inject constructor() : + SendFeeReloadTrigger, + SendFeeReloadListener, + SendFeeCheckReloadTrigger, + SendFeeCheckReloadListener { + + private val _reloadTriggerFlow = MutableSharedFlow() + override val reloadTriggerFlow = _reloadTriggerFlow.asSharedFlow() + + private val _checkReloadTriggerFlow = MutableSharedFlow() + override val checkReloadTriggerFlow = _checkReloadTriggerFlow.asSharedFlow() + + private val _checkReloadResultFlow = MutableSharedFlow() + override val checkReloadResultFlow = _checkReloadResultFlow.asSharedFlow() + + override suspend fun triggerUpdate(feeData: SendFeeData) { + _reloadTriggerFlow.emit(feeData) + } + + override suspend fun triggerCheckUpdate() { + _checkReloadTriggerFlow.emit(Unit) + } + + override suspend fun callbackCheckResult(isSuccess: Boolean) { + _checkReloadResultFlow.emit(isSuccess) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/analytics/SendFeeAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/analytics/SendFeeAnalyticEvents.kt new file mode 100644 index 0000000000..c65789aaa4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/analytics/SendFeeAnalyticEvents.kt @@ -0,0 +1,39 @@ +package com.tangem.features.send.v2.subcomponents.fee.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam + +internal sealed class SendFeeAnalyticEvents( + category: String, + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = category, event = event, params = params) { + + abstract val categoryName: String + + /** Selected fee (send after next screen opened) */ + data class SelectedFee( + override val categoryName: String, + val feeType: AnalyticsParam.FeeType, + ) : SendFeeAnalyticEvents( + category = categoryName, + event = "Fee Selected", + params = mapOf("Fee Type" to feeType.value), + ) + + /** Custom fee selected */ + data class CustomFeeButtonClicked( + override val categoryName: String, + ) : SendFeeAnalyticEvents( + category = categoryName, + event = "Custom Fee Clicked", + ) + + /** Custom fee edited */ + data class GasPriceInserter( + override val categoryName: String, + ) : SendFeeAnalyticEvents( + category = categoryName, + event = "Gas Price Inserted", + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/di/SendFeeModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/di/SendFeeModule.kt new file mode 100644 index 0000000000..53f687dcea --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/di/SendFeeModule.kt @@ -0,0 +1,29 @@ +package com.tangem.features.send.v2.subcomponents.fee.di + +import com.tangem.features.send.v2.subcomponents.fee.* +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +internal interface SendFeeModule { + + @Binds + @Singleton + fun provideSendFeeReloadTrigger(impl: DefaultSendFeeReloadTrigger): SendFeeReloadTrigger + + @Binds + @Singleton + fun provideSendFeeReloadListener(impl: DefaultSendFeeReloadTrigger): SendFeeReloadListener + + @Binds + @Singleton + fun provideSendFeeCheckReloadTrigger(impl: DefaultSendFeeReloadTrigger): SendFeeCheckReloadTrigger + + @Binds + @Singleton + fun provideSendFeeCheckReloadListener(impl: DefaultSendFeeReloadTrigger): SendFeeCheckReloadListener +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/FeeCalculation.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/FeeCalculation.kt new file mode 100644 index 0000000000..939bb73a64 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/FeeCalculation.kt @@ -0,0 +1,90 @@ +package com.tangem.features.send.v2.subcomponents.fee.model + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +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.utils.extensions.isZero +import java.math.BigDecimal +import java.math.RoundingMode + +private val FEE_MAX_DIFF = BigDecimal("5") + +/** + * Check and calculates subtracted amount + */ +internal fun checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable: Boolean, + cryptoCurrencyStatus: CryptoCurrencyStatus, + amountValue: BigDecimal, + feeValue: BigDecimal, + reduceAmountBy: BigDecimal, +): BigDecimal { + val balance = cryptoCurrencyStatus.value.amount ?: return amountValue + val isFeeCoverage = checkFeeCoverage( + isSubtractAvailable = isAmountSubtractAvailable, + balance = balance, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy, + ) + return if (isFeeCoverage) { + balance.minus(reduceAmountBy).minus(feeValue) + } else { + amountValue + } +} + +/** + * Check if custom fee is too high + */ +internal fun checkIfFeeTooHigh(feeSelectorState: FeeSelectorUM.Content): Pair { + val defaultResult = false to "" + val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return defaultResult + val highValue = multipleFees.priority.amount.value ?: return defaultResult + val customAmount = feeSelectorState.customValues.firstOrNull() ?: return defaultResult + val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) + val diff = if (highValue > BigDecimal.ZERO) { + customValue / highValue + } else { + BigDecimal.ZERO + } + val isFeeTooHigh = feeSelectorState.selectedType == FeeType.Custom && diff > FEE_MAX_DIFF + return isFeeTooHigh to diff.parseBigDecimal(0, RoundingMode.HALF_UP) +} + +/** + * Check if custom fee is too low + */ +internal fun checkIfFeeTooLow(feeSelectorUM: FeeSelectorUM.Content): Boolean { + val multipleFees = feeSelectorUM.fees as? TransactionFee.Choosable ?: return false + val minimumValue = multipleFees.minimum.amount.value ?: return false + val customAmount = feeSelectorUM.customValues.firstOrNull() ?: return false + val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) + + return feeSelectorUM.selectedType == FeeType.Custom && minimumValue > customValue +} + +/** + * Checks if sending amount with fee is greater than balance + */ +internal fun checkFeeCoverage( + isSubtractAvailable: Boolean, + balance: BigDecimal, + amountValue: BigDecimal, + feeValue: BigDecimal, + reduceAmountBy: BigDecimal?, +): Boolean { + if (!isSubtractAvailable) return false + val reducedBy = balance - (reduceAmountBy ?: BigDecimal.ZERO) + return reducedBy < amountValue + feeValue && reducedBy > feeValue && reducedBy >= amountValue +} + +/** + * Checks if fee exceeds fee paid currency balance + */ +fun checkExceedBalance(feeBalance: BigDecimal?, feeAmount: BigDecimal?): Boolean { + return feeAmount == null || feeBalance == null || feeAmount.isZero() || feeAmount > feeBalance +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt new file mode 100644 index 0000000000..107c608bb2 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeAlertFactory.kt @@ -0,0 +1,131 @@ +package com.tangem.features.send.v2.subcomponents.fee.model + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.features.send.v2.impl.R +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 java.math.BigDecimal +import javax.inject.Inject + +@ModelScoped +internal class SendFeeAlertFactory @Inject constructor( + private val messageSender: UiMessageSender, +) { + + fun checkAndShowAlerts(feeSelectorUM: FeeSelectorUM.Content, onConfirmClick: () -> Unit) { + val showFeeTooLow = checkAndShowFeeTooLow(feeSelectorUM, onConfirmClick) + val showFeeTooHigh = checkAndShowFeeTooHigh(feeSelectorUM, onConfirmClick) + + if (!showFeeTooLow && !showFeeTooHigh) { + onConfirmClick() + } + } + + /** + * Check if custom fee is too low + */ + private fun checkAndShowFeeTooLow(feeSelectorUM: FeeSelectorUM.Content, onConfirmClick: () -> Unit): Boolean { + val isFeeTooLow = checkIfFeeTooLow(feeSelectorUM) + if (isFeeTooLow) { + messageSender.send( + DialogMessage( + message = resourceReference(id = R.string.send_alert_fee_too_low_text), + dismissOnFirstAction = true, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_continue), + onClick = onConfirmClick, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } + return isFeeTooLow + } + + /** + * Check if custom fee is too high + */ + private fun checkAndShowFeeTooHigh(feeSelectorUM: FeeSelectorUM.Content, onConfirmClick: () -> Unit): Boolean { + val (isFeeTooHigh, diff) = checkIfFeeTooHigh(feeSelectorUM) + if (isFeeTooHigh) { + messageSender.send( + DialogMessage( + message = resourceReference( + id = R.string.send_alert_fee_too_high_text, + formatArgs = wrappedList(diff), + ), + dismissOnFirstAction = true, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_continue), + onClick = onConfirmClick, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } + return isFeeTooHigh + } + + fun getFeeUpdatedAlert(newFee: TransactionFee, feeUM: FeeUM, proceedAction: () -> Unit, stopAction: () -> Unit) { + if (feeUM !is FeeUM.Content) return + val feeSelectorUM = feeUM.feeSelectorUM as? FeeSelectorUM.Content ?: return + val newFee = when (newFee) { + is TransactionFee.Single -> newFee.normal + is TransactionFee.Choosable -> { + when (feeSelectorUM.selectedType) { + FeeType.Slow -> newFee.minimum + FeeType.Market -> newFee.normal + FeeType.Fast -> newFee.priority + FeeType.Custom -> return + } + } + } + + val newFeeValue = newFee.amount.value ?: BigDecimal.ZERO + val oldFeeValue = feeSelectorUM.selectedFee?.amount?.value ?: BigDecimal.ZERO + + if (newFeeValue > oldFeeValue) { + messageSender.send( + DialogMessage( + message = resourceReference(id = R.string.send_notification_high_fee_title), + dismissOnFirstAction = true, + firstActionBuilder = { + okAction { proceedAction(); onDismissRequest() } + }, + secondActionBuilder = { + cancelAction { stopAction(); onDismissRequest() } + }, + ), + ) + } else { + proceedAction() + } + } + + fun getFeeUnreachableErrorState(onFeeReload: () -> Unit) { + messageSender.send( + DialogMessage( + title = resourceReference(R.string.send_fee_unreachable_error_title), + message = resourceReference(R.string.send_fee_unreachable_error_text), + dismissOnFirstAction = true, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.warning_button_refresh), + onClick = onFeeReload, + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt new file mode 100644 index 0000000000..656699429a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeClickIntents.kt @@ -0,0 +1,16 @@ +package com.tangem.features.send.v2.subcomponents.fee.model + +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType + +internal interface SendFeeClickIntents { + + fun feeReload() + + fun onFeeSelectorClick(feeType: FeeType) + + fun onCustomFeeValueChange(index: Int, value: String) + + fun onReadMoreClick() + + fun onNextClick() +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6f2014fce0 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt @@ -0,0 +1,322 @@ +package com.tangem.features.send.v2.subcomponents.fee.model + +import androidx.compose.runtime.Stable +import com.tangem.blockchain.common.AmountType +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.IsFeeApproximateUseCase +import com.tangem.features.send.v2.common.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 +import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadListener +import com.tangem.features.send.v2.subcomponents.fee.analytics.SendFeeAnalyticEvents +import com.tangem.features.send.v2.subcomponents.fee.analytics.SendFeeAnalyticEvents.GasPriceInserter +import com.tangem.features.send.v2.subcomponents.fee.model.transformers.* +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.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.util.Locale +import javax.inject.Inject + +@Suppress("LongParameterList") +@Stable +@ModelScoped +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 urlOpener: UrlOpener, + private val analyticsEventHandler: AnalyticsEventHandler, + private val sendFeeAlertFactory: SendFeeAlertFactory, + private val feeReloadListener: SendFeeReloadListener, + private val feeCheckReloadListener: SendFeeCheckReloadListener, + private val feeCheckReloadTrigger: SendFeeCheckReloadTrigger, +) : Model(), SendFeeClickIntents { + + private val params: SendFeeComponentParams = paramsContainer.require() + + private val _uiState = MutableStateFlow(params.state) + val uiState = _uiState.asStateFlow() + + private val analyticsCategoryName = params.analyticsCategoryName + private val appCurrency = params.appCurrency + private val cryptoCurrencyStatus = params.cryptoCurrencyStatus + private val feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus + + private var feeJobHolder = JobHolder() + + init { + configFeeNavigation() + subscribeOnFeeReloadTriggerUpdates() + subscribeOnFeeCheckReloadTriggerUpdates() + initialState() + feeReload() + } + + fun updateState(state: FeeUM) { + _uiState.value = state + } + + override fun feeReload() { + loadFee( + amountValue = params.sendAmount, + destinationAddress = params.destinationAddress, + ) + } + + override fun onFeeSelectorClick(feeType: FeeType) { + _uiState.update( + SendFeeSelectTransformer( + feeType = feeType, + clickIntents = this@SendFeeModel, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ), + ) + updateFeeNotifications() + if (feeType == FeeType.Custom) { + analyticsEventHandler.send( + SendFeeAnalyticEvents.CustomFeeButtonClicked(categoryName = analyticsCategoryName), + ) + } + } + + override fun onCustomFeeValueChange(index: Int, value: String) { + _uiState.update( + SendFeeCustomValueChangeTransformer( + index = index, + value = value, + clickIntents = this@SendFeeModel, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ), + ) + updateFeeNotifications() + } + + override fun onReadMoreClick() { + val locale = if (Locale.getDefault().language == RU_LOCALE) RU_LOCALE else EN_LOCALE + val url = buildString { + append(FEE_READ_MORE_URL_FIRST_PART) + append(locale) + append(FEE_READ_MORE_URL_SECOND_PART) + } + urlOpener.openUrl(url) + } + + override fun onNextClick() { + val feeUM = uiState.value as? FeeUM.Content + val feeSelectorUM = feeUM?.feeSelectorUM as? FeeSelectorUM.Content + + if (feeSelectorUM != null) { + sendFeeAlertFactory.checkAndShowAlerts(feeSelectorUM) { + val isCustomFeeEdited = + feeSelectorUM.selectedFee?.amount?.value != feeSelectorUM.fees.normal.amount.value + if (feeSelectorUM.selectedType == FeeType.Custom && isCustomFeeEdited) { + analyticsEventHandler.send(GasPriceInserter(categoryName = analyticsCategoryName)) + } + analyticsEventHandler.send( + SendFeeAnalyticEvents.SelectedFee( + categoryName = analyticsCategoryName, + feeType = feeSelectorUM.selectedType.toAnalyticType(feeSelectorUM), + ), + ) + + navigate() + } + } else { + navigate() + } + } + + private fun initialState() { + if (uiState.value is FeeUM.Empty) { + _uiState.update( + SendFeeInitialStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + appCurrency = appCurrency, + ), + ) + } + } + + private fun subscribeOnFeeReloadTriggerUpdates() { + feeReloadListener.reloadTriggerFlow + .onEach { (amount, destination) -> + loadFee( + amountValue = amount ?: params.sendAmount, + destinationAddress = destination ?: params.destinationAddress, + ) + } + .launchIn(modelScope) + } + + private fun subscribeOnFeeCheckReloadTriggerUpdates() { + feeCheckReloadListener.checkReloadTriggerFlow + .onEach { checkLoadFee() } + .launchIn(modelScope) + } + + private fun saveResult() { + _uiState.update( + SendFeeCustomAutoFixTransformer( + clickIntents = this@SendFeeModel, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ), + ) + val params = params as? SendFeeComponentParams.FeeParams ?: return + params.callback.onFeeResult(uiState.value) + } + + private fun loadFee(amountValue: BigDecimal, destinationAddress: String) { + modelScope.launch { + val isShowLoading = (uiState.value as? FeeUM.Content)?.feeSelectorUM !is FeeSelectorUM.Content + if (isShowLoading) { + _uiState.update(SendFeeLoadingTransformer) + } + getFeeUseCase.invoke( + amount = amountValue, + destination = destinationAddress, + userWallet = params.userWallet, + cryptoCurrency = cryptoCurrencyStatus.currency, + ).fold( + ifRight = { + _uiState.update( + SendFeeLoadedTransformer( + fees = it, + clickIntents = this@SendFeeModel, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + isFeeApproximate = isFeeApproximate(it.normal.amount.type), + ), + ) + updateFeeNotifications() + }, + ifLeft = { feeError -> + if (isShowLoading) { + _uiState.update(SendFeeFailedTransformer(feeError)) + } + updateFeeNotifications() + }, + ) + }.saveIn(feeJobHolder) + } + + private fun checkLoadFee() { + modelScope.launch { + getFeeUseCase.invoke( + amount = params.sendAmount, + destination = params.destinationAddress, + userWallet = params.userWallet, + cryptoCurrency = cryptoCurrencyStatus.currency, + ).fold( + ifRight = { + sendFeeAlertFactory.getFeeUpdatedAlert( + newFee = it, + feeUM = uiState.value, + proceedAction = { + modelScope.launch { + feeCheckReloadTrigger.callbackCheckResult(true) + } + }, + stopAction = { + modelScope.launch { + feeCheckReloadTrigger.callbackCheckResult(false) + } + }, + ) + _uiState.update( + SendFeeLoadedTransformer( + fees = it, + clickIntents = this@SendFeeModel, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + isFeeApproximate = isFeeApproximate(it.normal.amount.type), + ), + ) + updateFeeNotifications() + }, + ifLeft = { feeError -> + feeCheckReloadTrigger.callbackCheckResult(false) + _uiState.update(SendFeeFailedTransformer(feeError)) + sendFeeAlertFactory.getFeeUnreachableErrorState(::feeReload) + updateFeeNotifications() + }, + ) + }.saveIn(feeJobHolder) + } + + private fun isFeeApproximate(amountType: AmountType): Boolean { + val networkId = feeCryptoCurrencyStatus.currency.network.id + return isFeeApproximateUseCase( + networkId = networkId, + amountType = amountType, + ) + } + + private fun updateFeeNotifications() { + _uiState.update( + SendFeeNotificationsTransformer( + cryptoCurrencyName = cryptoCurrencyStatus.currency.name, + onFeeReload = ::feeReload, + ), + ) + } + + private fun navigate() { + saveResult() + router.pop() + } + + private fun configFeeNavigation() { + val params = params as? SendFeeComponentParams.FeeParams ?: return + combine( + flow = uiState, + flow2 = params.currentRoute, + transform = { state, route -> state to route }, + ).onEach { (state, _) -> + params.callback.onNavigationResult( + NavigationUM.Content( + title = resourceReference(R.string.common_fee_selector_title), + subtitle = null, + backIconRes = R.drawable.ic_back_24, + backIconClick = router::pop, + primaryButton = ButtonsUM.PrimaryButtonUM( + text = resourceReference(R.string.common_continue), + isEnabled = state.isPrimaryButtonEnabled, + onClick = ::onNextClick, + ), + prevButton = null, + secondaryPairButtonsUM = null, + ), + ) + }.launchIn(modelScope) + } + + private companion object { + const val RU_LOCALE = "ru" + const val EN_LOCALE = "en" + const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/" + const val FEE_READ_MORE_URL_SECOND_PART = "/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/" + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt new file mode 100644 index 0000000000..2a5863ed1d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/FeeConverter.kt @@ -0,0 +1,50 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList + +internal class FeeConverter( + clickIntents: SendFeeClickIntents, + appCurrency: AppCurrency, + feeCryptoCurrencyStatus: CryptoCurrencyStatus, + private val fees: TransactionFee, +) : Converter { + + private val customFeeConverter = SendFeeCustomFieldConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + normalFee = fees.normal, + ) + + override fun convert(value: Data): Fee { + return when (fees) { + is TransactionFee.Choosable -> { + when (value.feeType) { + FeeType.Slow -> fees.minimum + FeeType.Market -> fees.normal + FeeType.Fast -> fees.priority + FeeType.Custom -> customFeeConverter.convertBack(value.customValues) + } + } + is TransactionFee.Single -> + when (value.feeType) { + FeeType.Market -> fees.normal + FeeType.Custom -> customFeeConverter.convertBack(value.customValues) + else -> fees.normal + } + } + } + + data class Data( + val customValues: ImmutableList, + val feeType: FeeType, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt new file mode 100644 index 0000000000..78304904a5 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/SendFeeCustomFieldConverter.kt @@ -0,0 +1,112 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.bitcoin.BitcoinCustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.kaspa.KaspaCustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.utils.converter.TwoWayConverter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +internal class SendFeeCustomFieldConverter( + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + private val normalFee: Fee, +) : TwoWayConverter> { + + private val ethereumCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { + EthereumCustomFeeConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + + private val bitcoinCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { + BitcoinCustomFeeConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + + private val kaspaCustomFeeConverter by lazy(LazyThreadSafetyMode.NONE) { + KaspaCustomFeeConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + + override fun convert(value: Fee): ImmutableList { + return when (value) { + is Fee.Ethereum -> ethereumCustomFeeConverter.convert(value) + is Fee.Bitcoin -> bitcoinCustomFeeConverter.convert(value) + is Fee.Kaspa -> kaspaCustomFeeConverter.convert(value) + else -> persistentListOf() + } + } + + override fun convertBack(value: ImmutableList): Fee { + return if (value.isEmpty()) { + normalFee + } else { + when (normalFee) { + is Fee.Ethereum -> ethereumCustomFeeConverter.convertBack(normalFee = normalFee, value = value) + is Fee.Bitcoin -> bitcoinCustomFeeConverter.convertBack(normalFee = normalFee, value = value) + is Fee.Kaspa -> kaspaCustomFeeConverter.convertBack(normalFee = normalFee, value = value) + else -> { + val customFee = value.firstOrNull() + Fee.Common( + normalFee.amount.copy( + value = customFee?.value?.parseToBigDecimal(customFee.decimals), + ), + ) + } + } + } + } + + fun onValueChange(feeSelectorState: FeeSelectorUM.Content, index: Int, value: String) = + when (val fee = feeSelectorState.fees.normal) { + is Fee.Ethereum -> ethereumCustomFeeConverter.onValueChange( + feeValue = fee, + customValues = feeSelectorState.customValues, + index = index, + value = value, + ) + is Fee.Bitcoin -> bitcoinCustomFeeConverter.onValueChange( + customValues = feeSelectorState.customValues, + index = index, + value = value, + txSize = fee.txSize, + ) + is Fee.Kaspa -> kaspaCustomFeeConverter.onValueChange( + customValues = feeSelectorState.customValues, + index = index, + value = value, + ) + else -> feeSelectorState.customValues + } + + fun tryAutoFixValue(feeSelectorState: FeeSelectorUM.Content) = when (feeSelectorState.fees) { + is TransactionFee.Choosable -> feeSelectorState.fees.minimum + is TransactionFee.Single -> feeSelectorState.fees.normal + }.let { + when (it) { + is Fee.Kaspa -> kaspaCustomFeeConverter.tryAutoFixValue( + minimumFee = it, + customValues = feeSelectorState.customValues, + ) + else -> feeSelectorState.customValues + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt new file mode 100644 index 0000000000..4250d48580 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt @@ -0,0 +1,14 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.ImmutableList + +internal interface CustomFeeConverter : Converter> { + fun convertBack(normalFee: T, value: ImmutableList): T +} + +internal fun MutableList.setEmpty(index: Int) { + set(index, this[index].copy(value = "")) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt new file mode 100644 index 0000000000..c2c024fab1 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt @@ -0,0 +1,143 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.bitcoin + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import com.tangem.lib.crypto.BlockchainUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode + +internal class BitcoinCustomFeeConverter( + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, +) : CustomFeeConverter { + + private val currencyStatus = feeCryptoCurrencyStatus.value + private val network = feeCryptoCurrencyStatus.currency.network.id.value + + override fun convert(value: Fee.Bitcoin): ImmutableList { + val feeValue = value.amount.value + return if (BlockchainUtils.isUseBitcoinFeeConverter(network)) { + persistentListOf( + CustomFeeFieldUM( + value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(), + decimals = value.amount.decimals, + symbol = value.amount.currencySymbol, + onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Companion.Next, + keyboardType = KeyboardType.Companion.Number, + ), + title = resourceReference(R.string.send_max_fee), + footer = resourceReference(R.string.send_bitcoin_custom_fee_footer), + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = feeValue, + appCurrency = appCurrency, + ), + keyboardActions = KeyboardActions(), + isReadonly = true, + ), + CustomFeeFieldUM( + value = toSatoshiPerByte( + amount = feeValue, + decimals = value.amount.decimals, + txSize = value.txSize, + ).toString(), + decimals = SATOSHI_DECIMALS, + symbol = "", + title = resourceReference(R.string.send_satoshi_per_byte_title), + footer = resourceReference(R.string.send_satoshi_per_byte_text), + onValueChange = { clickIntents.onCustomFeeValueChange(FEE_SATOSHI_INDEX, it) }, + keyboardOptions = KeyboardOptions( + imeAction = if (checkExceedBalance( + feeBalance = currencyStatus.amount, + feeAmount = feeValue, + ) + ) { + ImeAction.Companion.None + } else { + ImeAction.Companion.Done + }, + keyboardType = KeyboardType.Companion.Number, + ), + keyboardActions = KeyboardActions( + onDone = { clickIntents.onNextClick() }, + ), + ), + ) + } else { + persistentListOf() + } + } + + override fun convertBack(normalFee: Fee.Bitcoin, value: ImmutableList): Fee.Bitcoin { + val feeAmount = value[FEE_AMOUNT_INDEX].value.parseToBigDecimal(value[FEE_AMOUNT_INDEX].decimals) + val satoshiPerByte = value[FEE_SATOSHI_INDEX].value.parseToBigDecimal(value[FEE_SATOSHI_INDEX].decimals) + return normalFee.copy( + amount = normalFee.amount.copy(value = feeAmount), + satoshiPerByte = satoshiPerByte, + ) + } + + fun onValueChange( + customValues: ImmutableList, + index: Int, + value: String, + txSize: BigDecimal, + ): ImmutableList { + val mutableCustomValues = customValues.toMutableList() + return mutableCustomValues.apply { + if (index == FEE_SATOSHI_INDEX) { + val newSatoshiPerKb = value.parseToBigDecimal(this[FEE_SATOSHI_INDEX].decimals) + val newFeeAmount = newSatoshiPerKb.multiply(txSize) + .movePointLeft(this[FEE_AMOUNT_INDEX].decimals) + .setScale(this[FEE_AMOUNT_INDEX].decimals, RoundingMode.DOWN) + set( + FEE_AMOUNT_INDEX, + this[FEE_AMOUNT_INDEX].copy( + value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT_INDEX].decimals), + label = getFiatReference( + rate = feeCryptoCurrencyStatus.value.fiatRate, + value = newFeeAmount, + appCurrency = appCurrency, + ), + ), + ) + set(index, this[index].copy(value = value)) + } + }.toImmutableList() + } + + private fun toSatoshiPerByte(amount: BigDecimal?, decimals: Int, txSize: BigDecimal): BigDecimal? { + val newFeeAmount = amount?.movePointRight(decimals) + return newFeeAmount?.divide( + txSize, + SATOSHI_DECIMALS, + RoundingMode.HALF_UP, + )?.setScale(SATOSHI_DECIMALS, RoundingMode.HALF_UP) + } + + private companion object { + private const val FEE_AMOUNT_INDEX = 0 + private const val FEE_SATOSHI_INDEX = 1 + private const val SATOSHI_DECIMALS = 0 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt new file mode 100644 index 0000000000..c0828d3d60 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt @@ -0,0 +1,25 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import kotlinx.collections.immutable.ImmutableList + +/** + * Base ethereum custom fee converter + * + * @param T subtype of [Fee.Ethereum] + * +[REDACTED_AUTHOR] + */ +internal interface BaseEthereumCustomFeeConverter : CustomFeeConverter { + + fun getGasLimitIndex(feeValue: T): Int + + fun onValueChange( + feeValue: T, + customValues: ImmutableList, + index: Int, + value: String, + ): ImmutableList +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt new file mode 100644 index 0000000000..ce2ba52261 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt @@ -0,0 +1,126 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal class EthereumCustomFeeConverter( + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + feeCryptoCurrencyStatus: CryptoCurrencyStatus, +) : BaseEthereumCustomFeeConverter { + + private val currencyStatus = feeCryptoCurrencyStatus.value + + private val legacyFeeConverter = EthereumLegacyCustomFeeConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) + + private val eipFeeConverter = EthereumEIPCustomFeeConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + ) + + override fun convert(value: Fee.Ethereum): ImmutableList { + return buildList { + convertFeeValue(value).let(::add) + + when (value) { + is Fee.Ethereum.EIP1559 -> eipFeeConverter.convert(value) + is Fee.Ethereum.Legacy -> legacyFeeConverter.convert(value) + }.let(::addAll) + + convertGasLimitValue(value).let(::add) + } + .toImmutableList() + } + + override fun convertBack(normalFee: Fee.Ethereum, value: ImmutableList): Fee.Ethereum { + return when (normalFee) { + is Fee.Ethereum.EIP1559 -> eipFeeConverter.convertBack(normalFee = normalFee, value = value) + is Fee.Ethereum.Legacy -> legacyFeeConverter.convertBack(normalFee = normalFee, value = value) + } + } + + override fun getGasLimitIndex(feeValue: Fee.Ethereum): Int { + return when (feeValue) { + is Fee.Ethereum.EIP1559 -> eipFeeConverter.getGasLimitIndex(feeValue) + is Fee.Ethereum.Legacy -> legacyFeeConverter.getGasLimitIndex(feeValue) + } + } + + override fun onValueChange( + feeValue: Fee.Ethereum, + customValues: ImmutableList, + index: Int, + value: String, + ): ImmutableList { + return when (feeValue) { + is Fee.Ethereum.EIP1559 -> eipFeeConverter.onValueChange(feeValue, customValues, index, value) + is Fee.Ethereum.Legacy -> legacyFeeConverter.onValueChange(feeValue, customValues, index, value) + } + } + + private fun convertFeeValue(value: Fee.Ethereum): CustomFeeFieldUM { + val feeValue = value.amount.value + + return CustomFeeFieldUM( + value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(), + decimals = value.amount.decimals, + symbol = value.amount.currencySymbol, + onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT, it) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number), + title = resourceReference(R.string.send_max_fee), + footer = resourceReference(R.string.send_custom_amount_fee_footer), + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = feeValue, + appCurrency = appCurrency, + ), + keyboardActions = KeyboardActions(), + ) + } + + private fun convertGasLimitValue(value: Fee.Ethereum): CustomFeeFieldUM { + val isExceedBalance = checkExceedBalance(feeBalance = currencyStatus.amount, feeAmount = value.amount.value) + + return CustomFeeFieldUM( + value = value.gasLimit.toString(), + decimals = GAS_DECIMALS, + symbol = "", + title = resourceReference(R.string.send_gas_limit), + footer = resourceReference(R.string.send_gas_limit_footer), + onValueChange = { clickIntents.onCustomFeeValueChange(getGasLimitIndex(value), it) }, + keyboardOptions = KeyboardOptions( + imeAction = if (isExceedBalance) ImeAction.None else ImeAction.Done, + keyboardType = KeyboardType.Number, + ), + keyboardActions = KeyboardActions( + onDone = { clickIntents.onNextClick() }, + ), + ) + } + + companion object { + const val ETHEREUM_GAS_UNIT = "GWEI" + const val GIGA_DECIMALS = 9 + const val GAS_DECIMALS = 0 + const val FEE_AMOUNT = 0 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt new file mode 100644 index 0000000000..2a7d389e13 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt @@ -0,0 +1,204 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.ETHEREUM_GAS_UNIT +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.FEE_AMOUNT +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GAS_DECIMALS +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GIGA_DECIMALS +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.setEmpty +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.RoundingMode + +internal class EthereumEIPCustomFeeConverter( + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, +) : BaseEthereumCustomFeeConverter { + + private val currencyStatus = feeCryptoCurrencyStatus.value + + override fun convert(value: Fee.Ethereum.EIP1559): ImmutableList { + return persistentListOf( + CustomFeeFieldUM( + value = value.maxFeePerGas.toBigDecimal().movePointLeft(GIGA_DECIMALS).parseBigDecimal(GIGA_DECIMALS), + decimals = GIGA_DECIMALS, + symbol = ETHEREUM_GAS_UNIT, + title = resourceReference(R.string.send_custom_evm_max_fee), + footer = resourceReference(R.string.send_custom_evm_max_fee_footer), + onValueChange = { clickIntents.onCustomFeeValueChange(MAX_FEE, it) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number), + keyboardActions = KeyboardActions(), + ), + CustomFeeFieldUM( + value = value.priorityFee.toBigDecimal().movePointLeft(GIGA_DECIMALS).parseBigDecimal(GIGA_DECIMALS), + decimals = GIGA_DECIMALS, + symbol = ETHEREUM_GAS_UNIT, + title = resourceReference(R.string.send_custom_evm_priority_fee), + footer = resourceReference(R.string.send_custom_evm_priority_fee_footer), + onValueChange = { clickIntents.onCustomFeeValueChange(PRIORITY_FEE, it) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Number), + keyboardActions = KeyboardActions(), + ), + ) + } + + override fun convertBack( + normalFee: Fee.Ethereum.EIP1559, + value: ImmutableList, + ): Fee.Ethereum.EIP1559 { + val feeAmount = value[FEE_AMOUNT].value.parseToBigDecimal(value[FEE_AMOUNT].decimals) + val maxFeeDecimals = value[MAX_FEE].decimals + val maxFee = value[MAX_FEE].value.parseToBigDecimal(maxFeeDecimals) + .movePointRight(maxFeeDecimals) + .toBigInteger() + val priorityFeeDecimals = value[PRIORITY_FEE].decimals + val priorityFee = value[PRIORITY_FEE].value.parseToBigDecimal(priorityFeeDecimals) + .movePointRight(priorityFeeDecimals) + .toBigInteger() + val gasLimit = value[GAS_LIMIT].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger() + + return normalFee.copy( + amount = normalFee.amount.copy(value = feeAmount), + maxFeePerGas = maxFee, + priorityFee = priorityFee, + gasLimit = gasLimit, + ) + } + + override fun getGasLimitIndex(feeValue: Fee.Ethereum.EIP1559): Int = GAS_LIMIT + + override fun onValueChange( + feeValue: Fee.Ethereum.EIP1559, + customValues: ImmutableList, + index: Int, + value: String, + ): ImmutableList { + val mutableCustomValues = customValues.toMutableList() + return mutableCustomValues.apply { + when (index) { + FEE_AMOUNT -> setOnAmountChange(value, index) + MAX_FEE -> setOnMaxFeeChange(value, index) + GAS_LIMIT -> setOnGasLimitChange(value, index) + else -> set(index, this[index].copy(value = value)) + } + }.toImmutableList() + } + + private fun MutableList.setOnAmountChange(value: String, index: Int) { + val gasLimit = this[GAS_LIMIT].value.parseToBigDecimal(this[GAS_LIMIT].decimals) + if (value.isBlank()) { + setEmpty(FEE_AMOUNT) + setEmpty(MAX_FEE) + } else { + val newFeeAmountDecimal = value.parseToBigDecimal(this[FEE_AMOUNT].decimals) + val newFeeAmount = newFeeAmountDecimal.movePointRight(GIGA_DECIMALS) // from ETH to GWEI + + val newMaxFee = newFeeAmount.divide(gasLimit, this[MAX_FEE].decimals, RoundingMode.HALF_UP) + + set( + index = MAX_FEE, + element = this[MAX_FEE].copy(value = newMaxFee.parseBigDecimal(this[MAX_FEE].decimals)), + ) + + set( + index = index, + element = this[index].copy( + value = value, + label = getFiatReference( + rate = feeCryptoCurrencyStatus.value.fiatRate, + value = newFeeAmountDecimal, + appCurrency = appCurrency, + ), + ), + ) + } + } + + private fun MutableList.setOnMaxFeeChange(value: String, index: Int) { + val gasLimit = this[GAS_LIMIT].value.parseToBigDecimal(this[GAS_LIMIT].decimals) + + if (value.isBlank()) { + setEmpty(FEE_AMOUNT) + setEmpty(MAX_FEE) + } else { + val newMaxFee = value.parseToBigDecimal(this[MAX_FEE].decimals).movePointLeft(this[MAX_FEE].decimals) + val newFeeAmount = gasLimit * newMaxFee + set( + FEE_AMOUNT, + this[FEE_AMOUNT].copy( + value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT].decimals), + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = newFeeAmount, + appCurrency = appCurrency, + ), + ), + ) + set(index, this[index].copy(value = value)) + } + } + + private fun MutableList.setOnGasLimitChange(value: String, index: Int) { + if (value.isBlank()) { + setEmpty(FEE_AMOUNT) + setEmpty(GAS_LIMIT) + } else { + val newGasLimit = value.parseToBigDecimal(this[GAS_LIMIT].decimals) + + val maxFee = this[MAX_FEE].value.parseToBigDecimal(this[MAX_FEE].decimals) + .movePointLeft(this[MAX_FEE].decimals) // from GWEI to ETH + + val newFeeAmount = newGasLimit * maxFee + + set( + index = FEE_AMOUNT, + element = this[FEE_AMOUNT].copy( + value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT].decimals), + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = newFeeAmount, + appCurrency = appCurrency, + ), + ), + ) + + val isNotExceedBalance = checkExceedBalance( + feeBalance = currencyStatus.amount, + feeAmount = newFeeAmount, + ) + + set( + index = index, + element = this[index].copy( + value = value, + keyboardOptions = KeyboardOptions( + imeAction = if (!isNotExceedBalance) ImeAction.None else ImeAction.Done, + keyboardType = KeyboardType.Number, + ), + ), + ) + } + } + + private companion object { + const val MAX_FEE = 1 + const val PRIORITY_FEE = 2 + const val GAS_LIMIT = 3 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt new file mode 100644 index 0000000000..8b2f86beb7 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt @@ -0,0 +1,181 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.checkExceedBalance +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.ETHEREUM_GAS_UNIT +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.FEE_AMOUNT +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GAS_DECIMALS +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter.Companion.GIGA_DECIMALS +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.setEmpty +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.RoundingMode + +internal class EthereumLegacyCustomFeeConverter( + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + feeCryptoCurrencyStatus: CryptoCurrencyStatus, +) : BaseEthereumCustomFeeConverter { + + private val currencyStatus = feeCryptoCurrencyStatus.value + + override fun convert(value: Fee.Ethereum.Legacy): ImmutableList { + return persistentListOf( + CustomFeeFieldUM( + value = value.gasPrice.toBigDecimal().movePointLeft(GIGA_DECIMALS).parseBigDecimal(GIGA_DECIMALS), + decimals = GIGA_DECIMALS, + symbol = ETHEREUM_GAS_UNIT, + title = resourceReference(R.string.send_gas_price), + footer = resourceReference(R.string.send_gas_price_footer), + onValueChange = { clickIntents.onCustomFeeValueChange(GAS_PRICE, it) }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Number, + ), + keyboardActions = KeyboardActions(), + ), + ) + } + + override fun convertBack( + normalFee: Fee.Ethereum.Legacy, + value: ImmutableList, + ): Fee.Ethereum.Legacy { + val feeAmount = value[FEE_AMOUNT].value.parseToBigDecimal(value[FEE_AMOUNT].decimals) + val gasPrice = value[GAS_PRICE].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger() + val gasLimit = value[GAS_LIMIT].value.parseToBigDecimal(GAS_DECIMALS).toBigInteger() + + return normalFee.copy( + amount = normalFee.amount.copy(value = feeAmount), + gasPrice = gasPrice, + gasLimit = gasLimit, + ) + } + + override fun getGasLimitIndex(feeValue: Fee.Ethereum.Legacy): Int = GAS_LIMIT + + override fun onValueChange( + feeValue: Fee.Ethereum.Legacy, + customValues: ImmutableList, + index: Int, + value: String, + ): ImmutableList { + val mutableCustomValues = customValues.toMutableList() + return mutableCustomValues.apply { + when (index) { + FEE_AMOUNT -> setOnAmountChange(value, index) + GAS_PRICE -> setOnGasPriceChange(value, index) + GAS_LIMIT -> setOnGasLimitChange(value, index) + else -> set(index, this[index].copy(value = value)) + } + }.toImmutableList() + } + + private fun MutableList.setOnAmountChange(value: String, index: Int) { + val gasLimit = this[GAS_LIMIT].value.parseToBigDecimal(this[GAS_LIMIT].decimals) + if (value.isBlank()) { + setEmpty(FEE_AMOUNT) + setEmpty(GAS_PRICE) + } else { + val newFeeAmountDecimal = value.parseToBigDecimal(this[FEE_AMOUNT].decimals) + val newFeeAmount = newFeeAmountDecimal.movePointRight(this[GAS_PRICE].decimals) // from ETH to GWEI + val newGasPrice = newFeeAmount.divide(gasLimit, this[GAS_PRICE].decimals, RoundingMode.HALF_UP) + set(GAS_PRICE, this[GAS_PRICE].copy(value = newGasPrice.parseBigDecimal(this[GAS_PRICE].decimals))) + set( + index, + this[index].copy( + value = value, + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = newFeeAmountDecimal, + appCurrency = appCurrency, + ), + ), + ) + } + } + + private fun MutableList.setOnGasPriceChange(value: String, index: Int) { + val gasLimit = this[GAS_LIMIT].value.parseToBigDecimal(this[GAS_LIMIT].decimals) + if (value.isBlank()) { + setEmpty(FEE_AMOUNT) + setEmpty(GAS_PRICE) + } else { + val newGasPrice = value.parseToBigDecimal(this[GAS_PRICE].decimals) + .movePointLeft(this[GAS_PRICE].decimals) // from GWEI to ETH + val newFeeAmount = gasLimit * newGasPrice + set( + FEE_AMOUNT, + this[FEE_AMOUNT].copy( + value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT].decimals), + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = newFeeAmount, + appCurrency = appCurrency, + ), + ), + ) + set(index, this[index].copy(value = value)) + } + } + + private fun MutableList.setOnGasLimitChange(value: String, index: Int) { + if (value.isBlank()) { + setEmpty(FEE_AMOUNT) + setEmpty(GAS_LIMIT) + } else { + val newGasLimit = value.parseToBigDecimal(this[GAS_LIMIT].decimals) + val gasPrice = this[GAS_PRICE].value.parseToBigDecimal(this[GAS_PRICE].decimals) + .movePointLeft(this[GAS_PRICE].decimals) // from GWEI to ETH + + val newFeeAmount = newGasLimit * gasPrice + + set( + index = FEE_AMOUNT, + element = this[FEE_AMOUNT].copy( + value = newFeeAmount.parseBigDecimal(this[FEE_AMOUNT].decimals), + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = newFeeAmount, + appCurrency = appCurrency, + ), + ), + ) + + val isNotExceedBalance = checkExceedBalance( + feeBalance = currencyStatus.amount, + feeAmount = newFeeAmount, + ) + + set( + index = index, + element = this[index].copy( + value = value, + keyboardOptions = KeyboardOptions( + imeAction = if (!isNotExceedBalance) ImeAction.None else ImeAction.Done, + keyboardType = KeyboardType.Number, + ), + ), + ) + } + } + + private companion object { + const val GAS_PRICE = 1 + const val GAS_LIMIT = 2 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt new file mode 100644 index 0000000000..2714e7d9f6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt @@ -0,0 +1,131 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.kaspa + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.converters.custom.CustomFeeConverter +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.RoundingMode + +internal class KaspaCustomFeeConverter( + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + feeCryptoCurrencyStatus: CryptoCurrencyStatus, +) : CustomFeeConverter { + + private val currencyStatus = feeCryptoCurrencyStatus.value + + override fun convert(value: Fee.Kaspa): ImmutableList { + val feeValue = value.amount.value + return persistentListOf( + CustomFeeFieldUM( + value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(), + decimals = value.amount.decimals, + symbol = value.amount.currencySymbol, + onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT_INDEX, it) }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Companion.Next, + keyboardType = KeyboardType.Companion.Number, + ), + title = resourceReference(R.string.send_max_fee), + footer = resourceReference(R.string.send_custom_amount_fee_footer), + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = feeValue, + appCurrency = appCurrency, + ), + keyboardActions = KeyboardActions(), + ), + ) + } + + override fun convertBack(normalFee: Fee.Kaspa, value: ImmutableList): Fee.Kaspa { + val decimals = value[FEE_AMOUNT_INDEX].decimals + val feeAmount = value[FEE_AMOUNT_INDEX].value.parseToBigDecimal(decimals) + return normalFee.copy( + amount = normalFee.amount.copy(value = feeAmount), + mass = normalFee.mass, + feeRate = feeAmount + .divide(normalFee.mass.toBigDecimal(), decimals, RoundingMode.HALF_UP) + .movePointRight(decimals) + .toBigInteger(), + ) + } + + fun onValueChange( + customValues: ImmutableList, + index: Int, + value: String, + ): ImmutableList { + val mutableCustomValues = customValues.toMutableList() + return mutableCustomValues.apply { + when (index) { + FEE_AMOUNT_INDEX -> { + val valueDecimal = value.parseToBigDecimal(this[FEE_AMOUNT_INDEX].decimals) + set( + index, + this[index].copy( + value = value, + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = valueDecimal, + appCurrency = appCurrency, + ), + ), + ) + } + } + }.toImmutableList() + } + + fun tryAutoFixValue( + minimumFee: Fee.Kaspa, + customValues: ImmutableList, + ): ImmutableList { + val mutableCustomValues = customValues.toMutableList() + val minimumFeeAmountValue = minimumFee.amount.value + + return mutableCustomValues.apply { + // check that there is reveal transaction info (= krc-20 token transfer) + // return without changes otherwise + if (minimumFee.revealTransactionFee != null && minimumFeeAmountValue != null) { + getOrNull(FEE_AMOUNT_INDEX)?.let { + val valueDecimal = it.value.parseToBigDecimal(it.decimals) + // krc-20 transaction will be failed if custom fee value is less than minimum, + // so we set value to minimum in this case + if (valueDecimal < minimumFee.amount.value) { + val fixedValue = minimumFeeAmountValue.parseBigDecimal(it.decimals) + set( + FEE_AMOUNT_INDEX, + it.copy( + value = fixedValue, + label = getFiatReference( + rate = currencyStatus.fiatRate, + value = valueDecimal, + appCurrency = appCurrency, + ), + ), + ) + } + } + } + }.toImmutableList() + } + + private companion object { + private const val FEE_AMOUNT_INDEX = 0 + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomAutoFixTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomAutoFixTransformer.kt new file mode 100644 index 0000000000..65d07205da --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomAutoFixTransformer.kt @@ -0,0 +1,45 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +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.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.converters.SendFeeCustomFieldConverter +import com.tangem.utils.transformer.Transformer + +internal class SendFeeCustomAutoFixTransformer( + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, +) : Transformer { + + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content ?: return state + + val customFeeConverter = SendFeeCustomFieldConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + normalFee = feeSelectorUM.fees.normal, + ) + + return when (feeSelectorUM.selectedType) { + FeeType.Slow, + FeeType.Market, + FeeType.Fast, + -> state + FeeType.Custom -> { + val updatedCustomValues = customFeeConverter.tryAutoFixValue(feeSelectorUM) + state.copy( + feeSelectorUM = feeSelectorUM.copy( + customValues = updatedCustomValues, + selectedFee = customFeeConverter.convertBack(updatedCustomValues), + ), + ) + } + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomValueChangeTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomValueChangeTransformer.kt new file mode 100644 index 0000000000..5d95767e59 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeCustomValueChangeTransformer.kt @@ -0,0 +1,39 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +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.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.converters.SendFeeCustomFieldConverter +import com.tangem.utils.transformer.Transformer + +internal class SendFeeCustomValueChangeTransformer( + private val index: Int, + private val value: String, + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, +) : Transformer { + + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content ?: return state + + val customFeeConverter = SendFeeCustomFieldConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + normalFee = feeSelectorUM.fees.normal, + ) + + val updatedCustomValues = customFeeConverter.onValueChange(feeSelectorUM, index, value) + + return state.copy( + feeSelectorUM = feeSelectorUM.copy( + customValues = updatedCustomValues, + selectedFee = customFeeConverter.convertBack(updatedCustomValues), + ), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeFailedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeFailedTransformer.kt new file mode 100644 index 0000000000..e3f728dd23 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeFailedTransformer.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +import com.tangem.domain.transaction.error.GetFeeError +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.utils.transformer.Transformer + +internal class SendFeeFailedTransformer( + private val error: GetFeeError, +) : Transformer { + + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + + return state.copy( + feeSelectorUM = FeeSelectorUM.Error(error), + isPrimaryButtonEnabled = false, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeInitialStateTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeInitialStateTransformer.kt new file mode 100644 index 0000000000..723147ce78 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeInitialStateTransformer.kt @@ -0,0 +1,34 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +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.lib.crypto.BlockchainUtils.isTron +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +internal class SendFeeInitialStateTransformer( + cryptoCurrencyStatus: CryptoCurrencyStatus, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, + private val appCurrency: AppCurrency, +) : Transformer { + + private val cryptoCurrency = cryptoCurrencyStatus.currency + + override fun transform(prevState: FeeUM): FeeUM { + return FeeUM.Content( + isPrimaryButtonEnabled = false, + feeSelectorUM = FeeSelectorUM.Loading, + notifications = persistentListOf(), + rate = feeCryptoCurrencyStatus.value.fiatRate, + appCurrency = appCurrency, + isFeeApproximate = false, + isCustomSelected = false, + isFeeConvertibleToFiat = feeCryptoCurrencyStatus.currency.network.hasFiatFeeRate, + isTronToken = cryptoCurrency is CryptoCurrency.Token && + isTron(cryptoCurrency.network.id.value), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt new file mode 100644 index 0000000000..b1a7fb7467 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadedTransformer.kt @@ -0,0 +1,64 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +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.fee.model.converters.FeeConverter +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.fee.model.converters.SendFeeCustomFieldConverter +import com.tangem.utils.transformer.Transformer + +internal class SendFeeLoadedTransformer( + clickIntents: SendFeeClickIntents, + appCurrency: AppCurrency, + feeCryptoCurrencyStatus: CryptoCurrencyStatus, + private val fees: TransactionFee, + private val isFeeApproximate: Boolean, +) : Transformer { + + private val customFeeFieldConverter = SendFeeCustomFieldConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + normalFee = fees.normal, + ) + + private val feeConverter = FeeConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fees = fees, + ) + + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content + + val updatedFeeSelector = if (feeSelectorUM == null) { + FeeSelectorUM.Content( + fees = fees, + customValues = customFeeFieldConverter.convert(fees.normal), + selectedFee = fees.normal, + ) + } else { + FeeSelectorUM.Content( + fees = fees, + customValues = feeSelectorUM.customValues, + selectedType = feeSelectorUM.selectedType, + selectedFee = feeConverter.convert( + FeeConverter.Data( + feeType = feeSelectorUM.selectedType, + customValues = feeSelectorUM.customValues, + ), + ), + ) + } + + return state.copy( + feeSelectorUM = updatedFeeSelector, + isFeeApproximate = isFeeApproximate, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadingTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadingTransformer.kt new file mode 100644 index 0000000000..96c3e3d00a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeLoadingTransformer.kt @@ -0,0 +1,17 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +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.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +internal object SendFeeLoadingTransformer : Transformer { + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + return state.copy( + feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content ?: FeeSelectorUM.Loading, + notifications = persistentListOf(), + isPrimaryButtonEnabled = false, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNotificationsTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNotificationsTransformer.kt new file mode 100644 index 0000000000..d8416e2718 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeNotificationsTransformer.kt @@ -0,0 +1,51 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification +import com.tangem.core.ui.utils.parseToBigDecimal +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.extensions.isZero +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal class SendFeeNotificationsTransformer( + private val cryptoCurrencyName: String, + private val onFeeReload: () -> Unit, +) : Transformer { + + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + + val notifications = state.getNotifications() + return state.copy( + notifications = notifications, + isPrimaryButtonEnabled = state.isPrimaryButtonEnabled(notifications), + ) + } + + private fun FeeUM.Content.getNotifications() = buildList { + addFeeUnreachableNotification( + feeError = (feeSelectorUM as? FeeSelectorUM.Error)?.error, + tokenName = cryptoCurrencyName, + onReload = onFeeReload, + ) + }.toImmutableList() + + private fun FeeUM.Content.isPrimaryButtonEnabled(notifications: ImmutableList): Boolean { + val feeSelectorState = feeSelectorUM as? FeeSelectorUM.Content ?: return false + val customValue = feeSelectorState.customValues.firstOrNull() + + val isNotCustom = feeSelectorState.selectedType != FeeType.Custom + val isNotEmptyCustom = if (customValue != null) { + !customValue.value.parseToBigDecimal(customValue.decimals).isZero() && !isNotCustom + } else { + false + } + val noErrors = notifications.none { it is NotificationUM.Error } + + return noErrors && (isNotEmptyCustom || isNotCustom) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeSelectTransformer.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeSelectTransformer.kt new file mode 100644 index 0000000000..6ae4b1b0d3 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/transformers/SendFeeSelectTransformer.kt @@ -0,0 +1,46 @@ +package com.tangem.features.send.v2.subcomponents.fee.model.transformers + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +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.features.send.v2.subcomponents.fee.model.converters.FeeConverter +import com.tangem.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.utils.transformer.Transformer + +internal class SendFeeSelectTransformer( + private val feeType: FeeType, + private val clickIntents: SendFeeClickIntents, + private val appCurrency: AppCurrency, + private val feeCryptoCurrencyStatus: CryptoCurrencyStatus, +) : Transformer { + + override fun transform(prevState: FeeUM): FeeUM { + val state = prevState as? FeeUM.Content ?: return prevState + val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content ?: return state + + val feeConverter = FeeConverter( + clickIntents = clickIntents, + appCurrency = appCurrency, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + fees = feeSelectorUM.fees, + ) + + val updatedFeeSelectorState = feeSelectorUM.copy( + selectedType = feeType, + selectedFee = feeConverter.convert( + FeeConverter.Data( + customValues = feeSelectorUM.customValues, + feeType = feeType, + ), + ), + ) + + val isCustomFeeWasSelected = state.isCustomSelected || updatedFeeSelectorState.selectedType == FeeType.Custom + return state.copy( + isCustomSelected = isCustomFeeWasSelected, + feeSelectorUM = updatedFeeSelectorState, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/FeeBlock.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/FeeBlock.kt new file mode 100644 index 0000000000..10284bde92 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/FeeBlock.kt @@ -0,0 +1,126 @@ +package com.tangem.features.send.v2.subcomponents.fee.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.unit.dp +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.rows.SelectorRowItem +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.impl.R +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 + +@Composable +internal fun FeeBlock(feeUM: FeeUM, isClickEnabled: Boolean, onClick: () -> Unit) { + val feeUM = feeUM as? FeeUM.Content ?: return + val feeSelectorUM = feeUM.feeSelectorUM as? FeeSelectorUM.Content + val isEditingDisabled = feeSelectorUM?.fees is TransactionFee.Single + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .clickable(enabled = isClickEnabled && !isEditingDisabled, onClick = onClick) + .padding(12.dp), + ) { + Text( + text = stringResourceSafe(R.string.common_network_fee_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.secondary, + ) + + Box( + modifier = Modifier.padding(top = 8.dp), + ) { + val feeSelectorUM = feeUM.feeSelectorUM + val feeAmount = (feeSelectorUM as? FeeSelectorUM.Content)?.selectedFee?.amount + val (title, icon) = if (feeSelectorUM is FeeSelectorUM.Content) { + when (feeSelectorUM.selectedType) { + FeeType.Slow -> R.string.common_fee_selector_option_slow to R.drawable.ic_tortoise_24 + FeeType.Market -> R.string.common_fee_selector_option_market to R.drawable.ic_bird_24 + FeeType.Fast -> R.string.common_fee_selector_option_fast to R.drawable.ic_hare_24 + FeeType.Custom -> R.string.common_custom to R.drawable.ic_edit_24 + } + } else { + R.string.common_fee_selector_option_market to R.drawable.ic_bird_24 + } + SelectorRowItem( + titleRes = title, + iconRes = icon, + preDot = stringReference( + feeAmount?.value.format { + crypto( + symbol = feeAmount?.currencySymbol.orEmpty(), + decimals = feeAmount?.decimals ?: 0, + ).fee(canBeLower = feeUM.isFeeApproximate) + }, + ), + postDot = if (feeUM.isFeeConvertibleToFiat) { + getFiatReference(feeAmount?.value, feeUM.rate, feeUM.appCurrency) + } else { + null + }, + ellipsizeOffset = feeAmount?.currencySymbol?.length, + isSelected = true, + showDivider = false, + showSelectedAppearance = false, + paddingValues = PaddingValues(), + ) + FeeLoading(feeSelectorUM) + FeeError(feeSelectorUM) + } + } +} + +@Composable +private fun BoxScope.FeeLoading(feeSelectorUM: FeeSelectorUM) { + AnimatedContent( + targetState = feeSelectorUM, + label = "Fee Loading State Change", + modifier = Modifier.align(Alignment.CenterEnd), + ) { + if (it == FeeSelectorUM.Loading) { + RectangleShimmer( + radius = 3.dp, + modifier = Modifier.size( + height = 12.dp, + width = 90.dp, + ), + ) + } + } +} + +@Composable +private fun BoxScope.FeeError(feeSelectorUM: FeeSelectorUM) { + AnimatedContent( + targetState = feeSelectorUM, + label = "Fee Error State Change", + modifier = Modifier.align(Alignment.CenterEnd), + ) { + if (it is FeeSelectorUM.Error) { + Text( + text = EMPTY_BALANCE_SIGN, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.body2, + ) + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt new file mode 100644 index 0000000000..cf31789446 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendCustomFee.kt @@ -0,0 +1,82 @@ +package com.tangem.features.send.v2.subcomponents.fee.ui + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.containers.FooterContainer +import com.tangem.core.ui.components.inputrow.InputRowEnterAmount +import com.tangem.core.ui.components.inputrow.InputRowEnterInfoAmount +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.subcomponents.fee.ui.state.CustomFeeFieldUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun SendCustomFee( + customValues: ImmutableList, + selectedFee: FeeType, + hasNotifications: Boolean, + onValueChange: (Int, String) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = selectedFee == FeeType.Custom && customValues.isNotEmpty(), + label = "Custom Fee Selected Animation", + enter = expandVertically().plus(fadeIn()), + exit = shrinkVertically().plus(fadeOut()), + ) { + val bottomPadding = if (hasNotifications) 12.dp else 0.dp + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.padding(bottom = bottomPadding), + ) { + repeat(customValues.size) { index -> + val value = customValues[index] + FooterContainer( + footer = value.footer, + ) { + if (value.label != null) { + InputRowEnterInfoAmount( + text = value.value, + decimals = value.decimals, + symbol = value.symbol, + title = value.title, + info = value.label, + keyboardOptions = value.keyboardOptions, + keyboardActions = value.keyboardActions, + onValueChange = { onValueChange(index, it) }, + showDivider = false, + isReadOnly = value.isReadonly, + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) + } else { + InputRowEnterAmount( + text = value.value, + decimals = value.decimals, + title = value.title, + symbol = value.symbol, + onValueChange = { onValueChange(index, it) }, + keyboardOptions = value.keyboardOptions, + keyboardActions = value.keyboardActions, + showDivider = false, + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt new file mode 100644 index 0000000000..728f537a37 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendFeeContent.kt @@ -0,0 +1,79 @@ +package com.tangem.features.send.v2.subcomponents.fee.ui + +import androidx.compose.foundation.background +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +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.features.send.v2.subcomponents.fee.model.SendFeeClickIntents +import com.tangem.features.send.v2.subcomponents.notifications + +private const val FEE_SELECTOR_KEY = "FEE_SELECTOR_KEY" +private const val FEE_CUSTOM_KEY = "FEE_CUSTOM_KEY" + +@Composable +internal fun SendFeeContent(state: FeeUM, clickIntents: SendFeeClickIntents) { + if (state !is FeeUM.Content) return + + val notifications = state.notifications + val feeSelectorUM = state.feeSelectorUM as? FeeSelectorUM.Content + val isCustomSelected = feeSelectorUM?.selectedType == FeeType.Custom + val hasNotifications = notifications.isNotEmpty() + LazyColumn( + modifier = Modifier // Do not put fillMaxSize() in here + .background(TangemTheme.colors.background.tertiary) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + feeSelector(state, clickIntents) + if (feeSelectorUM != null) { + customFee( + feeSelectorUM = feeSelectorUM, + onValueChange = clickIntents::onCustomFeeValueChange, + hasNotifications = hasNotifications, + ) + } + notifications(notifications = notifications, hasPaddingAbove = isCustomSelected) + } +} + +private fun LazyListScope.feeSelector(state: FeeUM.Content, clickIntents: SendFeeClickIntents) { + item(key = FEE_SELECTOR_KEY) { + SendSpeedSelector( + state = state, + clickIntents = clickIntents, + modifier = Modifier.animateItem(), + ) + } +} + +internal fun LazyListScope.customFee( + feeSelectorUM: FeeSelectorUM.Content, + hasNotifications: Boolean, + onValueChange: (Int, String) -> Unit, + modifier: Modifier = Modifier, +) { + item(key = FEE_CUSTOM_KEY) { + SendCustomFee( + customValues = feeSelectorUM.customValues, + selectedFee = feeSelectorUM.selectedType, + hasNotifications = hasNotifications, + onValueChange = onValueChange, + modifier = modifier + .fillMaxWidth() + .animateItem() + .background(TangemTheme.colors.background.tertiary) + .padding(top = 12.dp), + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelector.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelector.kt new file mode 100644 index 0000000000..0437760435 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelector.kt @@ -0,0 +1,102 @@ +package com.tangem.features.send.v2.subcomponents.fee.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.v2.impl.R +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.features.send.v2.subcomponents.fee.model.SendFeeClickIntents + +@Suppress("LongMethod") +@Composable +internal fun SendSpeedSelector( + state: FeeUM.Content, + clickIntents: SendFeeClickIntents, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) { + SendSpeedSelectorItem( + titleRes = R.string.common_fee_selector_option_slow, + iconRes = R.drawable.ic_tortoise_24, + feeType = FeeType.Slow, + state = state, + onSelect = { clickIntents.onFeeSelectorClick(FeeType.Slow) }, + ) + SendSpeedSelectorItem( + titleRes = R.string.common_fee_selector_option_market, + iconRes = R.drawable.ic_bird_24, + feeType = FeeType.Market, + state = state, + onSelect = { clickIntents.onFeeSelectorClick(FeeType.Market) }, + ) + SendSpeedSelectorItem( + titleRes = R.string.common_fee_selector_option_fast, + iconRes = R.drawable.ic_hare_24, + feeType = FeeType.Fast, + state = state, + onSelect = { clickIntents.onFeeSelectorClick(FeeType.Fast) }, + ) + SendSpeedSelectorItem( + titleRes = R.string.common_custom, + iconRes = R.drawable.ic_edit_24, + feeType = FeeType.Custom, + state = state, + onSelect = { clickIntents.onFeeSelectorClick(FeeType.Custom) }, + ) + } + FooterText(clickIntents::onReadMoreClick) + } +} + +@Composable +private fun FooterText(onReadMoreClick: () -> Unit) { + val linkText = stringResourceSafe(R.string.common_read_more) + val fullString = stringResourceSafe(R.string.common_fee_selector_footer, linkText) + val linkTextPosition = fullString.length - linkText.length + val defaultStyle = TangemTheme.colors.text.tertiary + val linkStyle = TangemTheme.colors.text.accent + val annotatedString = remember(defaultStyle, linkStyle) { + buildAnnotatedString { + withStyle(SpanStyle(defaultStyle)) { + append(fullString.substring(0, linkTextPosition)) + } + withStyle(SpanStyle(linkStyle)) { + append(fullString.substring(linkTextPosition, fullString.length)) + } + } + } + + val click = { i: Int -> + val readMoreStyle = requireNotNull(annotatedString.spanStyles.getOrNull(1)) + if (i in readMoreStyle.start..readMoreStyle.end) { + onReadMoreClick() + } + } + + ClickableText( + text = annotatedString, + style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start), + modifier = Modifier.padding(top = 8.dp), + onClick = click, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelectorItem.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelectorItem.kt new file mode 100644 index 0000000000..3cc6d6042b --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/SendSpeedSelectorItem.kt @@ -0,0 +1,152 @@ +package com.tangem.features.send.v2.subcomponents.fee.ui + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.* +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.amountScreen.utils.getFiatReference +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.rows.SelectorRowItem +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.EMPTY_BALANCE_SIGN +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fee +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.parseToBigDecimal +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 + +@Composable +internal fun SendSpeedSelectorItem( + @StringRes titleRes: Int, + @DrawableRes iconRes: Int, + feeType: FeeType, + state: FeeUM.Content, + onSelect: () -> Unit, + modifier: Modifier = Modifier, +) { + val feeSelectorState = state.feeSelectorUM + val content = feeSelectorState as? FeeSelectorUM.Content + val amount = content?.getAmount(feeType) + val (showDivider, isVisible) = content.getDividerAndVisibility(feeType) + AnimatedVisibility( + visible = isVisible, + label = "Fee Selector Visibility Animation", + enter = expandVertically().plus(fadeIn()), + exit = shrinkVertically().plus(fadeOut()), + ) { + Box( + modifier = modifier + .fillMaxWidth() + .clickable { onSelect() }, + ) { + SelectorRowItem( + titleRes = titleRes, + iconRes = iconRes, + onSelect = onSelect, + modifier = modifier, + preDot = stringReference( + amount?.value.format { + crypto( + symbol = amount?.currencySymbol.orEmpty(), + decimals = amount?.decimals ?: 0, + ).fee(canBeLower = state.isFeeApproximate) + }, + ), + postDot = if (state.isFeeConvertibleToFiat) { + getFiatReference(amount?.value, state.rate, state.appCurrency) + } else { + null + }, + ellipsizeOffset = amount?.currencySymbol?.length, + isSelected = content?.selectedType == feeType, + showDivider = showDivider, + ) + FeeLoading(feeSelectorState) + FeeError(feeSelectorState) + } + } +} + +@Composable +private fun FeeLoading(feeSelectorState: FeeSelectorUM) { + Row { + SpacerWMax() + AnimatedVisibility( + visible = feeSelectorState == FeeSelectorUM.Loading, + label = "Fee Loading State Change", + modifier = Modifier.align(Alignment.CenterVertically), + ) { + RectangleShimmer( + radius = 3.dp, + modifier = Modifier + .padding( + vertical = 18.dp, + horizontal = 12.dp, + ) + .size( + height = 12.dp, + width = 90.dp, + ), + ) + } + } +} + +@Composable +private fun FeeError(feeSelectorState: FeeSelectorUM) { + Row { + SpacerWMax() + AnimatedVisibility( + visible = feeSelectorState is FeeSelectorUM.Error, + label = "Fee Error State Change", + modifier = Modifier.align(Alignment.CenterVertically), + ) { + Text( + text = EMPTY_BALANCE_SIGN, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.body2, + modifier = Modifier + .padding( + vertical = 14.dp, + horizontal = 12.dp, + ), + ) + } + } +} + +private fun FeeSelectorUM.Content.getAmount(feeType: FeeType): Amount? { + val choosableFees = fees as? TransactionFee.Choosable + val decimals = fees.normal.amount.decimals + val customValue = this.customValues.firstOrNull()?.value?.parseToBigDecimal(decimals) + val customAmount = fees.normal.amount.copy(value = customValue) + return when (feeType) { + FeeType.Slow -> choosableFees?.minimum?.amount + FeeType.Market -> fees.normal.amount + FeeType.Fast -> choosableFees?.priority?.amount + FeeType.Custom -> customAmount + } +} + +private fun FeeSelectorUM.Content?.getDividerAndVisibility(feeType: FeeType): Pair { + val hasCustomValues = !this?.customValues.isNullOrEmpty() + val isNotSingle = this?.fees !is TransactionFee.Single + return when (feeType) { + FeeType.Slow -> true to isNotSingle + FeeType.Market -> (isNotSingle || hasCustomValues) to true + FeeType.Fast -> hasCustomValues to isNotSingle + FeeType.Custom -> false to hasCustomValues + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt new file mode 100644 index 0000000000..5b52d2c6b8 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/CustomFeeFieldUM.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.subcomponents.fee.ui.state + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal data class CustomFeeFieldUM( + val value: String, + val onValueChange: (String) -> Unit, + val keyboardOptions: KeyboardOptions, + val keyboardActions: KeyboardActions, + val symbol: String?, + val decimals: Int, + val title: TextReference, + val footer: TextReference, + val label: TextReference? = null, + val isReadonly: Boolean = false, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt new file mode 100644 index 0000000000..a9f5aae1b4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeSelectorUM.kt @@ -0,0 +1,44 @@ +package com.tangem.features.send.v2.subcomponents.fee.ui.state + +import androidx.compose.runtime.Stable +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.transaction.error.GetFeeError +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Stable +internal sealed class FeeSelectorUM { + + data class Content( + val fees: TransactionFee, + val selectedType: FeeType = FeeType.Market, + val selectedFee: Fee?, + val customValues: ImmutableList = persistentListOf(), + ) : FeeSelectorUM() + + data object Loading : FeeSelectorUM() + + data class Error( + val error: GetFeeError, + ) : FeeSelectorUM() +} + +internal enum class FeeType { + Slow, + Market, + Fast, + Custom, + ; + + fun toAnalyticType(feeSelectorUM: FeeSelectorUM.Content): AnalyticsParam.FeeType = when (feeSelectorUM.fees) { + is TransactionFee.Single -> AnalyticsParam.FeeType.Fixed + is TransactionFee.Choosable -> when (feeSelectorUM.selectedType) { + Slow -> AnalyticsParam.FeeType.Min + Market -> AnalyticsParam.FeeType.Normal + Fast -> AnalyticsParam.FeeType.Max + Custom -> AnalyticsParam.FeeType.Custom + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt new file mode 100644 index 0000000000..6bf499e1c4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/ui/state/FeeUM.kt @@ -0,0 +1,32 @@ +package com.tangem.features.send.v2.subcomponents.fee.ui.state + +import androidx.compose.runtime.Stable +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.domain.appcurrency.model.AppCurrency +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +@Stable +internal sealed class FeeUM { + + abstract val isPrimaryButtonEnabled: Boolean + + data class Content( + override val isPrimaryButtonEnabled: Boolean, + val feeSelectorUM: FeeSelectorUM, + val rate: BigDecimal?, + val appCurrency: AppCurrency, + val isFeeConvertibleToFiat: Boolean, + val isFeeApproximate: Boolean, + val isCustomSelected: Boolean, + val isTronToken: Boolean, + val customValues: ImmutableList = persistentListOf(), + val notifications: ImmutableList, + val isEditingDisabled: Boolean = false, + ) : FeeUM() + + data class Empty( + override val isPrimaryButtonEnabled: Boolean = false, + ) : FeeUM() +} \ No newline at end of file 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 new file mode 100644 index 0000000000..14760575ab --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt @@ -0,0 +1,56 @@ +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.NotificationsModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.StateFlow +import java.math.BigDecimal + +internal class NotificationsComponent( + appComponentContext: AppComponentContext, + params: Params, +) : AppComponentContext by appComponentContext { + + private val model: NotificationsModel = getOrCreateModel(params) + + val state: StateFlow> = model.uiState + + fun LazyListScope.content( + state: ImmutableList, + modifier: Modifier = Modifier, + hasPaddingAbove: Boolean = false, + isClickDisabled: Boolean = false, + ) { + notifications( + notifications = state, + modifier = modifier, + hasPaddingAbove = hasPaddingAbove, + isClickDisabled = isClickDisabled, + ) + } + + data class Params( + val analyticsCategoryName: String, + val userWalletId: UserWalletId, + 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?, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsUpdateTrigger.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsUpdateTrigger.kt new file mode 100644 index 0000000000..bdcf4b4076 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsUpdateTrigger.kt @@ -0,0 +1,40 @@ +package com.tangem.features.send.v2.subcomponents.notifications + +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +interface NotificationsUpdateTrigger { + /** Flow triggers notifications update */ + val updateTriggerFlow: Flow + + /** Flow returns whether there is error notifications */ + val hasErrorFlow: Flow + + /** Trigger return callback with check result */ + suspend fun callbackHasError(hasError: Boolean) + + /** Trigger fee check reload */ + suspend fun triggerUpdate(data: NotificationData) +} + +@Singleton +internal class DefaultNotificationsUpdateTrigger @Inject constructor() : NotificationsUpdateTrigger { + + private val _updateTriggerFlow = MutableSharedFlow() + override val updateTriggerFlow = _updateTriggerFlow.asSharedFlow() + + private val _hasErrorFlow = MutableSharedFlow() + override val hasErrorFlow = _hasErrorFlow.asSharedFlow() + + override suspend fun callbackHasError(hasError: Boolean) { + _hasErrorFlow.emit(hasError) + } + + override suspend fun triggerUpdate(data: NotificationData) { + _updateTriggerFlow.emit(data) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/analytics/NotificationsAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/analytics/NotificationsAnalyticEvents.kt new file mode 100644 index 0000000000..eeaeb2e51d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/analytics/NotificationsAnalyticEvents.kt @@ -0,0 +1,32 @@ +package com.tangem.features.send.v2.subcomponents.notifications.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.TOKEN_PARAM + +internal sealed class NotificationsAnalyticEvents( + category: String, + event: String, + params: Map = mapOf(), +) : AnalyticsEvent(category = category, event = event, params = params) { + + abstract val categoryName: String + + /** If not enough fee notification is present */ + data class NoticeNotEnoughFee( + override val categoryName: String, + val token: String, + val blockchain: String, + ) : NotificationsAnalyticEvents( + category = categoryName, + event = "Notice - Not Enough Fee", + params = mapOf(TOKEN_PARAM to token, BLOCKCHAIN to blockchain), + ) + + data class NoticeFeeCoverage( + override val categoryName: String, + ) : NotificationsAnalyticEvents( + category = categoryName, + event = "Notice - Network Fee Coverage", + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/di/NotificationsModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/di/NotificationsModule.kt new file mode 100644 index 0000000000..2c52cdeab5 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/di/NotificationsModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.subcomponents.notifications.di + +import com.tangem.features.send.v2.subcomponents.notifications.DefaultNotificationsUpdateTrigger +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +internal object NotificationsModule { + + @Provides + @Singleton + fun providesNotificationsUpdateTrigger(): NotificationsUpdateTrigger { + return DefaultNotificationsUpdateTrigger() + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationData.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationData.kt new file mode 100644 index 0000000000..bf7e440869 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationData.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.v2.subcomponents.notifications.model + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.transaction.error.GetFeeError +import java.math.BigDecimal + +data class NotificationData( + val destinationAddress: String, + val memo: String?, + val amountValue: BigDecimal, + val reduceAmountBy: BigDecimal, + val isIgnoreReduce: Boolean, + 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/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt new file mode 100644 index 0000000000..6775e4a9b6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -0,0 +1,380 @@ +package com.tangem.features.send.v2.subcomponents.notifications.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addExceedBalanceNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addHighFeeWarningNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addMinimumAmountErrorNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification +import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications +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.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase +import com.tangem.domain.tokens.GetCurrencyCheckUseCase +import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase +import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceTrigger +import com.tangem.features.send.v2.subcomponents.fee.SendFeeData +import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadTrigger +import com.tangem.features.send.v2.subcomponents.fee.model.checkAndCalculateSubtractedAmount +import com.tangem.features.send.v2.subcomponents.fee.model.checkFeeCoverage +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsUpdateTrigger +import com.tangem.features.send.v2.subcomponents.notifications.analytics.NotificationsAnalyticEvents +import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.math.BigDecimal +import javax.inject.Inject + +@Suppress("LongParameterList", "LargeClass") +@Stable +@ModelScoped +internal class NotificationsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val appRouter: AppRouter, + private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, + private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, + private val validateTransactionUseCase: ValidateTransactionUseCase, + private val sendFeeReloadTrigger: SendFeeReloadTrigger, + private val sendAmountReduceTrigger: SendAmountReduceTrigger, + private val notificationsUpdateTrigger: NotificationsUpdateTrigger, + private val analyticsEventHandler: AnalyticsEventHandler, +) : Model() { + + private val params: NotificationsComponent.Params = paramsContainer.require() + + private val analyticsCategoryName = params.analyticsCategoryName + private val userWalletId = params.userWalletId + private val cryptoCurrencyStatus = params.cryptoCurrencyStatus + private val feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus + 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 val _uiState = MutableStateFlow>(persistentListOf()) + val uiState = _uiState.asStateFlow() + + private var isAmountSubtractAvailable = false + + init { + subscribeToNotificationUpdateTrigger() + checkIfSubtractAvailable() + } + + private fun subscribeToNotificationUpdateTrigger() { + notificationsUpdateTrigger.updateTriggerFlow + .onEach { updateState(it) } + .launchIn(modelScope) + } + + private fun checkIfSubtractAvailable() { + modelScope.launch { + isAmountSubtractAvailable = isAmountSubtractAvailableUseCase(userWalletId, currency).getOrElse { false } + buildNotifications() + } + } + + 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 + + buildNotifications() + } + + private suspend fun buildNotifications() { + val notifications = buildList { + addFeeUnreachableNotification( + tokenStatus = cryptoCurrencyStatus, + coinStatus = feeCryptoCurrencyStatus, + feeError = feeError, + onReload = { + modelScope.launch { + sendFeeReloadTrigger.triggerUpdate( + feeData = SendFeeData( + amount = amountValue, + destinationAddress = destinationAddress, + ), + ) + } + }, + onClick = ::showTokenDetails, + ) + addDomainNotifications( + destinationAddress = destinationAddress, + memo = memo, + amountValue = amountValue, + reduceAmountBy = reduceAmountBy, + fee = fee, + ) + } + + notificationsUpdateTrigger.callbackHasError(notifications.any { it is NotificationUM.Error }) + + _uiState.value = notifications.toImmutableList() + } + + private fun showTokenDetails(currency: CryptoCurrency) { + appRouter.pop { isSuccess -> + if (isSuccess) { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = userWalletId, + currency = currency, + ), + ) + } + } + } + + private suspend fun MutableList.addDomainNotifications( + destinationAddress: String, + memo: String?, + amountValue: BigDecimal, + reduceAmountBy: BigDecimal, + fee: Fee?, + ) { + val balance = cryptoCurrencyStatus.value.amount ?: return + val feeValue = fee?.amount?.value ?: return + val isFeeCoverage = checkFeeCoverage( + isSubtractAvailable = isAmountSubtractAvailable, + balance = balance, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy, + ) + val sendingAmount = checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = amountValue, + feeValue = feeValue, + reduceAmountBy = reduceAmountBy, + ) + val feeCurrencyBalanceAfterTransaction = getFeeCurrencyBalanceAfterTx( + sendingAmount = sendingAmount, + feeValue = feeValue, + ) + val currencyCheck = getCurrencyCheckUseCase( + userWalletId = userWalletId, + currencyStatus = cryptoCurrencyStatus, + amount = sendingAmount, + fee = feeValue, + recipientAddress = destinationAddress, + feeCurrencyBalanceAfterTransaction = feeCurrencyBalanceAfterTransaction, + ) + + addErrorNotifications( + sendingAmount = sendingAmount, + feeValue = feeValue, + currencyCheck = currencyCheck, + ) + addWarningNotifications( + destinationAddress = destinationAddress, + memo = memo, + enteredAmount = amountValue, + fee = fee, + feeValue = feeValue, + sendingAmount = sendingAmount, + isFeeCoverage = isFeeCoverage, + currencyCheck = currencyCheck, + ) + } + + private fun getFeeCurrencyBalanceAfterTx(sendingAmount: BigDecimal, feeValue: BigDecimal): BigDecimal? { + val sendingCurrencyBalance = cryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded + val feeCurrencyBalance = feeCryptoCurrencyStatus.value as? CryptoCurrencyStatus.Loaded + if (feeCryptoCurrencyStatus.value !is CryptoCurrencyStatus.Loaded) return null + return when { + feeCryptoCurrencyStatus == cryptoCurrencyStatus -> sendingCurrencyBalance?.let { + it.amount - sendingAmount - feeValue + } + else -> feeCurrencyBalance?.let { it.amount - feeValue } + } + } + + private suspend fun MutableList.addErrorNotifications( + sendingAmount: BigDecimal, + feeValue: BigDecimal, + currencyCheck: CryptoCurrencyCheck, + ) { + val currencyWarning = getBalanceNotEnoughForFeeWarningUseCase( + fee = feeValue, + userWalletId = userWalletId, + tokenStatus = cryptoCurrencyStatus, + coinStatus = feeCryptoCurrencyStatus, + ).getOrNull() + + addExceedBalanceNotification( + feeAmount = feeValue, + sendingAmount = sendingAmount, + isSubtractionAvailable = isAmountSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) + addExceedsBalanceNotification( + cryptoCurrencyWarning = currencyWarning, + cryptoCurrencyStatus = cryptoCurrencyStatus, + shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(currency.network.backendId), + onClick = ::showTokenDetails, + onAnalyticsEvent = { + analyticsEventHandler.send( + NotificationsAnalyticEvents.NoticeNotEnoughFee( + categoryName = analyticsCategoryName, + token = cryptoCurrencyStatus.currency.symbol, + blockchain = cryptoCurrencyStatus.currency.network.name, + ), + ) + }, + ) + if (!BlockchainUtils.isCardano(currency.network.id.value)) { + addDustWarningNotification( + dustValue = currencyCheck.dustValue, + feeValue = feeValue, + sendingAmount = sendingAmount, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = feeCryptoCurrencyStatus, + ) + } + addTransactionLimitErrorNotification( + currencyCheck = currencyCheck, + sendingAmount = sendingAmount, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCurrencyStatus = feeCryptoCurrencyStatus, + feeValue = feeValue, + onReduceClick = { reduceTo, _ -> + modelScope.launch { + sendAmountReduceTrigger.triggerReduceTo(reduceTo) + } + }, + ) + addReserveAmountErrorNotification( + reserveAmount = currencyCheck.reserveAmount, + sendingAmount = sendingAmount, + cryptoCurrency = currency, + isAccountFunded = currencyCheck.isAccountFunded, + ) + addMinimumAmountErrorNotification( + minimumSendAmount = currencyCheck.minimumSendAmount, + sendingAmount = sendingAmount, + cryptoCurrency = currency, + ) + } + + private suspend fun MutableList.addWarningNotifications( + destinationAddress: String, + memo: String?, + enteredAmount: BigDecimal, + sendingAmount: BigDecimal, + fee: Fee?, + feeValue: BigDecimal, + isFeeCoverage: Boolean, + currencyCheck: CryptoCurrencyCheck, + ) { + val validationError = validateTransactionUseCase( + userWalletId = userWalletId, + amount = amountValue.convertToSdkAmount(cryptoCurrencyStatus.currency), + fee = fee, + memo = memo, + destination = destinationAddress, + network = cryptoCurrencyStatus.currency.network, + ).leftOrNull() + + addRentExemptionNotification( + rentWarning = currencyCheck.rentWarning, + ) + + addExistentialWarningNotification( + existentialDeposit = currencyCheck.existentialDeposit, + feeAmount = feeValue, + sendingAmount = sendingAmount, + cryptoCurrencyStatus = cryptoCurrencyStatus, + onReduceClick = { reduceBy, reduceByDiff, _ -> + modelScope.launch { + sendAmountReduceTrigger.triggerReduceBy( + ReduceByData( + reduceAmountBy = reduceBy, + reduceAmountByDiff = reduceByDiff, + ), + ) + } + }, + ) + addFeeCoverageNotification( + isFeeCoverage = isFeeCoverage, + enteredAmountValue = enteredAmount, + sendingValue = sendingAmount, + appCurrency = appCurrency, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ) + addValidateTransactionNotifications( + dustValue = currencyCheck.dustValue.orZero(), + minAdaValue = (fee as? Fee.CardanoToken)?.minAdaValue, + validationError = validationError, + cryptoCurrency = currency, + onReduceClick = { reduceTo, _ -> + modelScope.launch { + sendAmountReduceTrigger.triggerReduceTo(reduceTo) + } + }, + ) + addHighFeeWarningNotification( + enteredAmountValue = enteredAmount, + cryptoCurrencyStatus = cryptoCurrencyStatus, + ignoreAmountReduce = isIgnoreReduce, + onReduceClick = { reduceBy, reduceByDiff, _ -> + modelScope.launch { + sendAmountReduceTrigger.triggerReduceBy( + ReduceByData( + reduceAmountBy = reduceBy, + reduceAmountByDiff = reduceByDiff, + ), + ) + } + }, + onCloseClick = { + modelScope.launch { + sendAmountReduceTrigger.triggerIgnoreReduce() + } + }, + ) + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/ui/NotificationsContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/ui/NotificationsContent.kt new file mode 100644 index 0000000000..aa9dd0d609 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/ui/NotificationsContent.kt @@ -0,0 +1,48 @@ +package com.tangem.features.send.v2.subcomponents.notifications.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.res.TangemTheme +import kotlinx.collections.immutable.ImmutableList + +internal fun LazyListScope.notifications( + notifications: ImmutableList, + modifier: Modifier = Modifier, + hasPaddingAbove: Boolean = false, + isClickDisabled: Boolean = false, +) { + itemsIndexed( + items = notifications, + key = { _, item -> item::class.java }, + contentType = { _, item -> item::class.java }, + itemContent = { i, item -> + val topPadding = if (i == 0 && hasPaddingAbove) 0.dp else 12.dp + Notification( + config = item.config, + modifier = modifier + .padding(top = topPadding) + .animateItem(), + containerColor = when (item) { + is NotificationUM.Error.TokenExceedsBalance, + is NotificationUM.Warning.NetworkFeeUnreachable, + is NotificationUM.Warning.HighFeeError, + -> TangemTheme.colors.background.action + else -> TangemTheme.colors.button.disabled + }, + iconTint = when (item) { + is NotificationUM.Error.TokenExceedsBalance, + is NotificationUM.Warning, + -> null + is NotificationUM.Error -> TangemTheme.colors.icon.warning + is NotificationUM.Info -> TangemTheme.colors.icon.accent + }, + isEnabled = !isClickDisabled, + ) + }, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/res/drawable/ic_send_hint_shape_12.xml b/features/send-v2/impl/src/main/res/drawable/ic_send_hint_shape_12.xml new file mode 100644 index 0000000000..5a336de030 --- /dev/null +++ b/features/send-v2/impl/src/main/res/drawable/ic_send_hint_shape_12.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/DefaultSendRouter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/DefaultSendRouter.kt index 788ea93ba2..0eaedec9ff 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/DefaultSendRouter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/DefaultSendRouter.kt @@ -4,7 +4,6 @@ import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.navigation.url.UrlOpener -import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId import javax.inject.Inject @@ -34,10 +33,7 @@ internal class DefaultSendRouter @Inject constructor( override fun openQrCodeScanner(network: String) { router.push( - AppRoute.QrScanning( - source = SourceType.SEND, - networkName = network, - ), + AppRoute.QrScanning(source = AppRoute.QrScanning.Source.Send(network)), ) } } \ No newline at end of file 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 c161cea93a..882cbbae9a 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 @@ -36,8 +36,9 @@ import com.tangem.domain.tokens.* import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.domain.transaction.error.ValidateAddressError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase @@ -116,7 +117,7 @@ internal class SendModel @Inject constructor( private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, @DelayedWork private val coroutineScope: CoroutineScope, private val innerRouter: InnerSendRouter, - private val appRouter: AppRouter, + appRouter: AppRouter, paramsContainer: ParamsContainer, validateTransactionUseCase: ValidateTransactionUseCase, getCurrencyCheckUseCase: GetCurrencyCheckUseCase, @@ -419,7 +420,10 @@ internal class SendModel @Inject constructor( return filterNot { it.isLocked } .mapNotNull { wallet -> val addresses = if (!wallet.isMultiCurrency) { - getCryptoCurrencyUseCase(wallet.walletId).getOrNull()?.let { + getCryptoCurrencyUseCase( + userWallet = wallet, + cryptoCurrencyId = cryptoCurrency.id.value, + ).getOrNull()?.let { if (it.network.id == cryptoCurrency.network.id) { getNetworkAddressesUseCase.invokeSync(wallet.walletId, it.network) } else { @@ -704,7 +708,7 @@ internal class SendModel @Inject constructor( }.saveIn(memoValidationJobHolder) } - private suspend fun validateAddress(value: String): Either = runCatching { + private suspend fun validateAddress(value: String): AddressValidationResult = runCatching { val maybeValidAddress = validateWalletAddressUseCase( userWalletId = userWalletId, network = cryptoCurrency.network, @@ -713,10 +717,10 @@ internal class SendModel @Inject constructor( ) onEnteredValidAddress(maybeValidAddress.isLeft()) maybeValidAddress - }.getOrElse { ValidateAddressError.DataError(it).left() } + }.getOrElse { AddressValidation.Error.DataError(it).left() } private fun validateMemo(value: String?): Boolean { - return value?.let { validateWalletMemoUseCase(cryptoCurrency.network, it).getOrElse { false } } ?: true + return value?.let { validateWalletMemoUseCase(cryptoCurrency.network, it).isRight() } != false } private suspend fun checkIfXrpAddressValue(value: String): Boolean { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountPastedTriggerDismissConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountPastedTriggerDismissConverter.kt index 3766d7c9c9..a1d6b22fb4 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountPastedTriggerDismissConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountPastedTriggerDismissConverter.kt @@ -19,7 +19,7 @@ internal class SendAmountPastedTriggerDismissConverter( return state.copyWrapped( isEditState = isEditState, - amountState = AmountPastedTriggerDismissTransformer().transform(amountState), + amountState = AmountPastedTriggerDismissTransformer.transform(amountState), ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/confirm/SendNotificationFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/confirm/SendNotificationFactory.kt index 87b25f2824..6b36be9240 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/confirm/SendNotificationFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/confirm/SendNotificationFactory.kt @@ -1,9 +1,7 @@ package com.tangem.features.send.impl.presentation.state.confirm -import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.blockchainsdk.utils.minimalAmount import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification @@ -29,13 +27,14 @@ import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents +import com.tangem.features.send.impl.presentation.model.SendClickIntents import com.tangem.features.send.impl.presentation.state.SendStates import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiStateType import com.tangem.features.send.impl.presentation.state.StateRouter import com.tangem.features.send.impl.presentation.state.fee.* -import com.tangem.features.send.impl.presentation.model.SendClickIntents import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold import com.tangem.lib.crypto.BlockchainUtils.isTezos import com.tangem.utils.Provider import com.tangem.utils.extensions.orZero @@ -74,6 +73,7 @@ internal class SendNotificationFactory( val sendState = state.sendState ?: return@map persistentListOf() val feeState = state.getFeeState(isEditState) ?: return@map persistentListOf() val amountState = state.getAmountState(isEditState) as? AmountState.Data ?: return@map persistentListOf() + val recipientState = state.getRecipientState(isEditState) val amountValue = amountState.amountTextField.cryptoAmount.value.orZero() val feeValue = feeState.fee?.amount?.value.orZero() @@ -118,6 +118,7 @@ internal class SendNotificationFactory( ) addWarningNotifications( amountState = amountState, + recipientState = recipientState, feeState = feeState, sendState = sendState, sendingAmount = sendingAmount, @@ -233,6 +234,7 @@ internal class SendNotificationFactory( private suspend fun MutableList.addWarningNotifications( amountState: AmountState.Data, + recipientState: SendStates.RecipientState?, feeState: SendStates.FeeState, sendState: SendStates.SendState, sendingAmount: BigDecimal, @@ -247,8 +249,8 @@ internal class SendNotificationFactory( userWalletId = userWalletId, amount = amountValue.convertToSdkAmount(cryptoCurrencyStatus.currency), fee = feeState.fee, - memo = null, - destination = "", + memo = recipientState?.memoTextField?.value.orEmpty(), + destination = recipientState?.addressTextField?.value.orEmpty(), network = cryptoCurrencyStatus.currency.network, ).leftOrNull() } @@ -294,7 +296,7 @@ internal class SendNotificationFactory( val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO val isTezos = isTezos(cryptoCurrencyStatus.currency.network.id.value) - val threshold = Blockchain.Tezos.minimalAmount() + val threshold = getTezosThreshold() val isTotalBalance = sendAmount >= balance && balance > threshold if (!ignoreAmountReduce && isTotalBalance && isTezos) { add( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/RecipientSendFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/RecipientSendFactory.kt index c3fdbfd71b..3b0b10aaad 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/RecipientSendFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/RecipientSendFactory.kt @@ -1,20 +1,18 @@ package com.tangem.features.send.impl.presentation.state.recipient -import arrow.core.Either -import arrow.core.getOrElse import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.txhistory.models.TxHistoryItem -import com.tangem.domain.transaction.error.ValidateAddressError +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase +import com.tangem.domain.txhistory.models.TxHistoryItem import com.tangem.features.send.impl.R import com.tangem.features.send.impl.presentation.domain.AvailableWallet import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.StateRouter import com.tangem.utils.Provider import kotlinx.collections.immutable.toPersistentList -import timber.log.Timber internal class RecipientSendFactory( private val stateRouterProvider: Provider, @@ -66,10 +64,7 @@ internal class RecipientSendFactory( ) } - fun getOnRecipientAddressValidState( - value: String, - maybeValidAddress: Either, - ): SendUiState { + fun getOnRecipientAddressValidState(value: String, maybeValidAddress: AddressValidationResult): SendUiState { val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val state = currentStateProvider() val isEditState = stateRouterProvider().isEditState @@ -78,10 +73,7 @@ internal class RecipientSendFactory( val isValidMemo = validateWalletMemoUseCase( memo = recipientState.memoTextField?.value.orEmpty(), network = cryptoCurrencyStatus.currency.network, - ).getOrElse { - Timber.e("Failed to validateWalletMemoUseCase: $it") - false - } + ).isRight() return state.copyWrapped( isEditState = isEditState, @@ -92,10 +84,10 @@ internal class RecipientSendFactory( error = maybeValidAddress.fold( ifLeft = { when (it) { - ValidateAddressError.InvalidAddress -> resourceReference( + AddressValidation.Error.InvalidAddress -> resourceReference( R.string.send_recipient_address_error, ) - ValidateAddressError.AddressInWallet -> resourceReference( + AddressValidation.Error.AddressInWallet -> resourceReference( R.string.send_error_address_same_as_wallet, ) else -> null @@ -143,10 +135,7 @@ internal class RecipientSendFactory( val isValidMemo = validateWalletMemoUseCase( memo = value, network = cryptoCurrencyStatus.currency.network, - ).getOrElse { - Timber.e("Failed to validateWalletMemoUseCase: $it") - false - } + ).isRight() return state.copyWrapped( isEditState = isEditState, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt index 313451a067..9451e29e99 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/RecipientBlock.kt @@ -29,17 +29,11 @@ internal fun RecipientBlock( isEditingDisabled: Boolean, onClick: () -> Unit, ) { - val backgroundColor = if (isEditingDisabled) { - TangemTheme.colors.button.disabled - } else { - TangemTheme.colors.background.action - } - Column( modifier = Modifier .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(backgroundColor) + .background(TangemTheme.colors.background.action) .clickable(enabled = !isClickDisabled && !isEditingDisabled, onClick = onClick) .padding(TangemTheme.dimens.spacing12), ) { 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 b5155c36ac..63648d1762 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 @@ -16,6 +16,8 @@ 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.navigation.share.ShareManager +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -34,6 +36,7 @@ import com.tangem.domain.staking.model.stakekit.* import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction +import com.tangem.domain.staking.utils.getValidatorsCount import com.tangem.domain.tokens.* import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus @@ -114,6 +117,7 @@ internal class StakingModel @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getActionsUseCase: GetActionsUseCase, private val getYieldUseCase: GetYieldUseCase, + private val checkAccountInitializedUseCase: CheckAccountInitializedUseCase, private val paramsInterceptorHolder: ParamsInterceptorHolder, private val shareManager: ShareManager, @DelayedWork private val coroutineScope: CoroutineScope, @@ -154,6 +158,7 @@ internal class StakingModel @Inject constructor( return invalidatePendingTransactionsUseCase( balanceItems = yieldBalance?.balance?.items ?: emptyList(), processingActions = processingActions, + token = yield.token, ).getOrElse { emptyList() } } @@ -229,49 +234,62 @@ internal class StakingModel @Inject constructor( } override fun onNextClick(balanceState: BalanceState?) { - val isInitialInfoStep = value.currentStep == StakingStep.InitialInfo - val noBalanceState = balanceState == null - val noYieldBalanceData = cryptoCurrencyStatus.value.yieldBalance !is YieldBalance.Data + modelScope.launch { + val isInitialInfoStep = value.currentStep == StakingStep.InitialInfo + val noBalanceState = balanceState == null + val noYieldBalanceData = cryptoCurrencyStatus.value.yieldBalance !is YieldBalance.Data + val isAccountInitialized = checkAccountInitializedUseCase.invoke( + userWalletId = userWalletId, + network = cryptoCurrencyStatus.currency.network, + ).getOrElse { + Timber.e(it) + false + } - when { - isInitialInfoStep && noBalanceState && yield.allValidatorsFull && noYieldBalanceData -> { - stakingEventFactory.createStakingValidatorsUnavailableAlert() - return - } - isInitialInfoStep && noBalanceState -> { - val list = buildList { - SetConfirmationStateInitTransformer( - isEnter = true, - isExplicitExit = false, - balanceState = null, - cryptoCurrencyStatus = cryptoCurrencyStatus, - stakingApproval = stakingApproval, - stakingAllowance = stakingAllowance, - yieldArgs = yield.args, - ).let(::add) - if (yield.args.enter.isPartialAmountDisabled) { - ValidatorSelectChangeTransformer( - selectedValidator = yield.preferredValidators.firstOrNull(), - yield = yield, - ).let(::add) - SetAmountDataTransformer( - clickIntents = this@StakingModel, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - userWalletProvider = Provider { userWallet }, - appCurrencyProvider = Provider { appCurrency }, - ).let(::add) - AmountMaxValueStateTransformer( - cryptoCurrencyStatus = cryptoCurrencyStatus, - minimumTransactionAmount = minimumTransactionAmount, - actionType = uiState.value.actionType, - yield = yield, - ).let(::add) - } + when { + isInitialInfoStep && noBalanceState && yield.allValidatorsFull && noYieldBalanceData -> { + stakingEventFactory.createStakingValidatorsUnavailableAlert() + return@launch + } + isInitialInfoStep && noBalanceState && !isAccountInitialized -> { + stakingEventFactory.createInitializeAccountAlert() + return@launch + } + isInitialInfoStep && noBalanceState -> { + val list = buildList { + SetConfirmationStateInitTransformer( + isEnter = true, + isExplicitExit = false, + balanceState = null, + cryptoCurrencyStatus = cryptoCurrencyStatus, + stakingApproval = stakingApproval, + stakingAllowance = stakingAllowance, + yieldArgs = yield.args, + ).let(::add) + if (yield.args.enter.isPartialAmountDisabled) { + ValidatorSelectChangeTransformer( + selectedValidator = yield.preferredValidators.firstOrNull(), + yield = yield, + ).let(::add) + SetAmountDataTransformer( + clickIntents = this@StakingModel, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + userWalletProvider = Provider { userWallet }, + appCurrencyProvider = Provider { appCurrency }, + ).let(::add) + AmountMaxValueStateTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + minimumTransactionAmount = minimumTransactionAmount, + actionType = uiState.value.actionType, + yield = yield, + ).let(::add) + } + } + stateController.updateAll(*list.toTypedArray()) } - stateController.updateAll(*list.toTypedArray()) } + stakingStateRouter.onNextClick() } - stakingStateRouter.onNextClick() } override fun getFee() { @@ -497,19 +515,34 @@ internal class StakingModel @Inject constructor( analyticsEventHandler.send(StakingAnalyticsEvent.ButtonRewards) val rewardsValidators = stateController.value.rewardsValidatorsState as? StakingStates.RewardsValidatorsState.Data - val rewards = rewardsValidators?.rewards - if (rewards != null && rewards.isSingleItem()) { - onActiveStake(rewards.first()) - } else { - analyticsEventHandler.send( - StakingAnalyticsEvent.ButtonValidator( - source = StakeScreenSource.Info, - ), + + val initialInfoState = stateController.value.initialInfoState as? StakingStates.InitialInfoState.Data + val yieldBalance = initialInfoState?.yieldBalance as? InnerYieldBalanceState.Data + val rewardBlockType = yieldBalance?.reward?.rewardBlockType + val rewardPendingActionConstraints = yieldBalance?.reward?.rewardConstraints + + if (rewardBlockType == RewardBlockType.RewardsRequirementsError) { + stakingEventFactory.createStakingRewardsMinimumRequirementsErrorAlert( + cryptoCurrencyName = cryptoCurrencyStatus.currency.name, + cryptoAmountValue = rewardPendingActionConstraints?.amountArg?.minimum?.format { + crypto(cryptoCurrencyStatus.currency) + }.orEmpty(), ) - stateController.update { - value.copy(actionType = StakingActionCommonType.Pending.Rewards) + } else { + val rewards = rewardsValidators?.rewards + if (rewards != null && rewards.isSingleItem()) { + onActiveStake(rewards.first()) + } else { + analyticsEventHandler.send( + StakingAnalyticsEvent.ButtonValidator( + source = StakeScreenSource.Info, + ), + ) + stateController.update { + value.copy(actionType = StakingActionCommonType.Pending.Rewards) + } + stakingStateRouter.showRewardsValidators() } - stakingStateRouter.showRewardsValidators() } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt index f795c3195d..db102751d3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/InnerYieldBalanceState.kt @@ -2,18 +2,14 @@ package com.tangem.features.staking.impl.presentation.state import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.staking.model.stakekit.BalanceType -import com.tangem.domain.staking.model.stakekit.PendingAction -import com.tangem.domain.staking.model.stakekit.RewardBlockType -import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.* import kotlinx.collections.immutable.ImmutableList import java.math.BigDecimal +@Immutable internal sealed class InnerYieldBalanceState { data class Data( - val rewardsCrypto: String, - val rewardsFiat: String, - val rewardBlockType: RewardBlockType, + val reward: YieldReward, val isActionable: Boolean, val balances: ImmutableList, ) : InnerYieldBalanceState() @@ -37,4 +33,12 @@ internal data class BalanceState( val validator: Yield.Validator?, val pendingActions: ImmutableList, val isPending: Boolean, +) + +@Immutable +internal data class YieldReward( + val rewardsCrypto: String, + val rewardsFiat: String, + val rewardBlockType: RewardBlockType, + val rewardConstraints: PendingActionConstraints?, ) \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt index ed5cb2cdad..e663307922 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/YieldBalancesConverter.kt @@ -6,8 +6,11 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.* +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.presentation.state.InnerYieldBalanceState +import com.tangem.features.staking.impl.presentation.state.YieldReward import com.tangem.lib.crypto.BlockchainUtils.isBSC import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.Provider @@ -29,11 +32,11 @@ internal class YieldBalancesConverter( val appCurrency = appCurrencyProvider() val cryptoCurrency = cryptoCurrencyStatus.currency - val yieldBalance = cryptoCurrencyStatus.value.yieldBalance + val yieldBalance = cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data val balanceToShowItems = balancesToShowProvider() - return if (yieldBalance is YieldBalance.Data || balanceToShowItems.any { it.isPending }) { - val cryptoRewardsValue = (yieldBalance as? YieldBalance.Data)?.getRewardStakingBalance() + return if (yieldBalance != null || balanceToShowItems.any { it.isPending }) { + val cryptoRewardsValue = yieldBalance?.getRewardStakingBalance() val fiatRate = cryptoCurrencyStatus.value.fiatRate val fiatRewardsValue = if (fiatRate != null && cryptoRewardsValue != null) { @@ -41,17 +44,25 @@ internal class YieldBalancesConverter( } else { null } - val (type, isActionable) = getRewardBlockType() + val type = getRewardBlockType() + val pendingRewardsConstraints = yieldBalance?.balance?.items + ?.firstOrNull { it.type == BalanceType.REWARDS } + ?.pendingActionsConstraints + ?.firstOrNull { it.type == StakingActionType.CLAIM_REWARDS } + InnerYieldBalanceState.Data( - rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) }, - rewardsFiat = fiatRewardsValue.format { - fiat( - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) - }, - rewardBlockType = type, - isActionable = isActionable, + reward = YieldReward( + rewardsCrypto = cryptoRewardsValue.format { crypto(cryptoCurrency) }, + rewardsFiat = fiatRewardsValue.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + rewardBlockType = type, + rewardConstraints = pendingRewardsConstraints, + ), + isActionable = type.isActionable, balances = balanceToShowItems.mapBalances(), ) } else { @@ -66,7 +77,7 @@ internal class YieldBalancesConverter( .sortedBy { it.type.order } .toPersistentList() - private fun getRewardBlockType(): Pair { + private fun getRewardBlockType(): RewardBlockType { val blockchainId = cryptoCurrencyStatus.currency.network.id.value val yieldBalance = cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data val rewards = yieldBalance?.balance?.items @@ -76,9 +87,10 @@ internal class YieldBalancesConverter( val isRewardsClaimable = rewards?.isNotEmpty() == true return when { - isSolana(blockchainId) || isBSC(blockchainId) -> RewardBlockType.RewardUnavailable to false - isRewardsClaimable -> RewardBlockType.Rewards to isActionable - else -> RewardBlockType.NoRewards to false + isSolana(blockchainId) || isBSC(blockchainId) -> RewardBlockType.RewardUnavailable + isRewardsClaimable && isActionable -> RewardBlockType.Rewards + isRewardsClaimable && !isActionable -> RewardBlockType.RewardsRequirementsError + else -> RewardBlockType.NoRewards } } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt index 2e6c9aebf2..661b19506c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingAlertUM.kt @@ -61,4 +61,24 @@ internal sealed class StakingAlertUM : AlertUM { ) override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) } + + data object InitializeAccount : StakingAlertUM() { + override val onConfirmClick: (() -> Unit)? = null + override val title: TextReference = resourceReference(id = R.string.staking_error_no_validators_title) + override val message: TextReference = resourceReference(id = R.string.staking_notification_ton_activate_account) + override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) + } + + data class RewardsMinimumRequirementsError( + val cryptoCurrencyName: String, + val cryptoAmountValue: String, + ) : StakingAlertUM() { + override val onConfirmClick: (() -> Unit)? = null + override val title: TextReference? = null + override val message: TextReference = resourceReference( + id = R.string.staking_details_min_rewards_notification, + formatArgs = wrappedList(cryptoCurrencyName, cryptoAmountValue), + ) + override val confirmButtonText: TextReference = resourceReference(id = R.string.common_ok) + } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt index 6d54195e71..bb1b446118 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/events/StakingEventFactory.kt @@ -46,4 +46,20 @@ internal class StakingEventFactory( val alert = StakingEvent.ShowAlert(alert = StakingAlertUM.ValidatorsUnavailable) stateController.updateEvent(alert) } + + fun createInitializeAccountAlert() { + val alert = StakingEvent.ShowAlert(alert = StakingAlertUM.InitializeAccount) + stateController.updateEvent(alert) + } + + fun createStakingRewardsMinimumRequirementsErrorAlert(cryptoCurrencyName: String, cryptoAmountValue: String) { + stateController.updateEvent( + StakingEvent.ShowAlert( + alert = StakingAlertUM.RewardsMinimumRequirementsError( + cryptoCurrencyName = cryptoCurrencyName, + cryptoAmountValue = cryptoAmountValue, + ), + ), + ) + } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index 0aa07c6011..1c9a799920 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt @@ -1,9 +1,9 @@ package com.tangem.features.staking.impl.presentation.state.previewdata +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.domain.staking.model.stakekit.RewardBlockType import com.tangem.domain.staking.model.stakekit.Yield @@ -11,6 +11,7 @@ import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.YieldReward import kotlinx.collections.immutable.persistentListOf internal object InitialStakingStatePreview { @@ -63,9 +64,12 @@ internal object InitialStakingStatePreview { val stateWithYield = defaultState.copy( yieldBalance = InnerYieldBalanceState.Data( - rewardsFiat = "100 $", - rewardsCrypto = "100 SOL", - rewardBlockType = RewardBlockType.RewardUnavailable, + reward = YieldReward( + rewardsFiat = "100 $", + rewardsCrypto = "100 SOL", + rewardBlockType = RewardBlockType.RewardUnavailable, + rewardConstraints = null, + ), isActionable = true, balances = persistentListOf( BalanceState( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInitTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInitTransformer.kt index 4e5592683d..6e596ae2ec 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInitTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateInitTransformer.kt @@ -43,28 +43,7 @@ internal class SetConfirmationStateInitTransformer( @Suppress("CyclomaticComplexMethod") override fun transform(prevState: StakingUiState): StakingUiState { - val actionType = when { - isEnter -> StakingActionCommonType.Enter( - skipEnterAmount = yieldArgs.enter.isPartialAmountDisabled, - ) - isImplicitExit || isExplicitExit -> StakingActionCommonType.Exit(isPartiallyUnstakeDisabled(prevState)) - else -> when (pendingAction?.type) { - StakingActionType.STAKE -> StakingActionCommonType.Pending.Stake( - skipEnterAmount = yieldArgs.enter.isPartialAmountDisabled, - ) - StakingActionType.UNSTAKE -> StakingActionCommonType.Exit( - partiallyUnstakeDisabled = isPartiallyUnstakeDisabled(prevState), - ) - StakingActionType.CLAIM_REWARDS, - StakingActionType.RESTAKE_REWARDS, - -> StakingActionCommonType.Pending.Rewards - StakingActionType.VOTE_LOCKED, - StakingActionType.RESTAKE, - -> StakingActionCommonType.Pending.Restake - else -> StakingActionCommonType.Pending.Other - } - } - + val actionType = getActionType(prevState) return prevState.copy( actionType = actionType, balanceState = balanceState, @@ -86,6 +65,26 @@ internal class SetConfirmationStateInitTransformer( ) } + private fun getActionType(prevState: StakingUiState): StakingActionCommonType { + val isPartialEnterAmountDisabled = yieldArgs.enter.isPartialAmountDisabled + val isPartialExitAmountDisabled = isPartiallyUnstakeDisabled(prevState) + return when { + isEnter -> StakingActionCommonType.Enter(isPartialEnterAmountDisabled) + isImplicitExit || isExplicitExit -> StakingActionCommonType.Exit(isPartialExitAmountDisabled) + else -> when (pendingAction?.type) { + StakingActionType.STAKE -> StakingActionCommonType.Pending.Stake(isPartialEnterAmountDisabled) + StakingActionType.UNSTAKE -> StakingActionCommonType.Exit(isPartialExitAmountDisabled) + StakingActionType.CLAIM_REWARDS, + StakingActionType.RESTAKE_REWARDS, + -> StakingActionCommonType.Pending.Rewards + StakingActionType.VOTE_LOCKED, + StakingActionType.RESTAKE, + -> StakingActionCommonType.Pending.Restake + else -> StakingActionCommonType.Pending.Other + } + } + } + private fun isPartiallyUnstakeDisabled(state: StakingUiState): Boolean { val isSolana = BlockchainUtils.isSolana(state.cryptoCurrencyBlockchainId) val isValidatorPreferred = balanceState?.validator?.preferred == true diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountPasteDismissStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountPasteDismissStateTransformer.kt index b2d7752952..fa01ef2dec 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountPasteDismissStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountPasteDismissStateTransformer.kt @@ -8,7 +8,7 @@ internal class AmountPasteDismissStateTransformer : Transformer override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( - amountState = AmountPastedTriggerDismissTransformer().transform(prevState.amountState), + amountState = AmountPastedTriggerDismissTransformer.transform(prevState.amountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt index f82dff2923..f301a048e9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt @@ -98,9 +98,7 @@ internal class AddStakingNotificationsTransformer( onReload = prevState.clickIntents::getFee, feeValue = feeValue, ) - addStakingErrorNotifications( - stakingError = stakingError, - ) + addStakingErrorNotifications(stakingError = stakingError, onReload = prevState.clickIntents::getFee) // warnings addWarningNotifications( prevState = prevState, @@ -136,7 +134,10 @@ internal class AddStakingNotificationsTransformer( ) } - private fun MutableList.addStakingErrorNotifications(stakingError: StakingError?) { + private fun MutableList.addStakingErrorNotifications( + stakingError: StakingError?, + onReload: () -> Unit, + ) { when (stakingError) { is StakingError.StakeKitApiError -> { if (stakingError.message != StakingErrors.MinimumAmountNotReachedError.message) { @@ -146,9 +147,7 @@ internal class AddStakingNotificationsTransformer( } } null -> Unit - else -> add( - StakingNotification.Error.Common(subtitle = stringReference(stakingError.toString())), - ) + else -> add(NotificationUM.Warning.NetworkFeeUnreachable(onReload)) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt index 480126eb20..f9f71dd51e 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/validator/ValidatorSelectChangeTransformer.kt @@ -38,7 +38,7 @@ internal class ValidatorSelectChangeTransformer( chosenValidator = selectedValidator ?: yield.preferredValidators.first(), availableValidators = filteredValidators, isPrimaryButtonEnabled = true, - isClickable = true, + isClickable = yield.preferredValidators.size > 1, activeValidator = activeValidator, isVisibleOnConfirmation = isEnter || isRestake || isVoteLocked, ), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt index 49d1aa49dd..42f7777f9f 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingConfirmationContent.kt @@ -71,7 +71,7 @@ internal fun StakingConfirmationContent( isClickable = !isTransactionInProgress, onClick = clickIntents::openValidators, ) - StakingFeeBlock(feeState = state.feeState, isTransactionSent = isTransactionSent) + StakingFeeBlock(feeState = state.feeState) NotificationsBlock(notifications = state.notifications) Spacer(Modifier) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index a815b1b370..5e8683d6a9 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.Density import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.inputrow.InputRowDefault import com.tangem.core.ui.components.inputrow.InputRowImageInfo import com.tangem.core.ui.components.list.roundedListWithDividersItems @@ -38,19 +39,18 @@ import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent -import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.staking.model.stakekit.BalanceType import com.tangem.domain.staking.model.stakekit.RewardBlockType import com.tangem.features.staking.impl.R +import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.features.staking.impl.presentation.state.BalanceState import com.tangem.features.staking.impl.presentation.state.InnerYieldBalanceState import com.tangem.features.staking.impl.presentation.state.StakingStates import com.tangem.features.staking.impl.presentation.state.previewdata.InitialStakingStatePreview import com.tangem.features.staking.impl.presentation.state.stub.StakingClickIntentsStub -import com.tangem.features.staking.impl.presentation.model.StakingClickIntents import com.tangem.utils.StringsSigns.DOT import com.tangem.utils.extensions.orZero @@ -214,14 +214,17 @@ private fun StakingRewardBlock( onRewardsClick: () -> Unit, isBalanceHidden: Boolean, ) { - val (text, textColor) = when (yieldBalanceState.rewardBlockType) { - RewardBlockType.Rewards -> { + val reward = yieldBalanceState.reward + val (text, textColor) = when (yieldBalanceState.reward.rewardBlockType) { + RewardBlockType.RewardsRequirementsError, + RewardBlockType.Rewards, + -> { annotatedReference { - append(yieldBalanceState.rewardsFiat.orMaskWithStars(isBalanceHidden)) + append(reward.rewardsFiat.orMaskWithStars(isBalanceHidden)) appendSpace() append(DOT) appendSpace() - append(yieldBalanceState.rewardsCrypto.orMaskWithStars(isBalanceHidden)) + append(reward.rewardsCrypto.orMaskWithStars(isBalanceHidden)) } to TangemTheme.colors.text.primary1 } RewardBlockType.RewardUnavailable -> { @@ -232,7 +235,7 @@ private fun StakingRewardBlock( resourceReference(R.string.staking_details_no_rewards_to_claim) to TangemTheme.colors.text.tertiary } } - val isShowIcon = yieldBalanceState.rewardBlockType == RewardBlockType.Rewards && yieldBalanceState.isActionable + val isShowIcon = reward.rewardBlockType == RewardBlockType.Rewards && yieldBalanceState.isActionable InputRowDefault( title = resourceReference(R.string.staking_rewards), text = text, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt index cfd283c0d1..0b4973970b 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt @@ -32,17 +32,12 @@ import com.tangem.features.staking.impl.presentation.state.FeeState import java.math.BigDecimal @Composable -internal fun StakingFeeBlock(feeState: FeeState, isTransactionSent: Boolean) { - val backgroundColor = if (isTransactionSent) { - TangemTheme.colors.background.action - } else { - TangemTheme.colors.button.disabled - } +internal fun StakingFeeBlock(feeState: FeeState) { Column( modifier = Modifier .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(backgroundColor) + .background(TangemTheme.colors.background.action) .padding(TangemTheme.dimens.spacing12), ) { Text( @@ -144,10 +139,7 @@ private fun BoxScope.FeeError(feeState: FeeState) { @Composable private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) value: FeeState) { TangemThemePreview { - StakingFeeBlock( - feeState = value, - isTransactionSent = false, - ) + StakingFeeBlock(feeState = value) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingActionSelectorBottomSheet.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingActionSelectorBottomSheet.kt index 64c371054c..9a1c63bc5b 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingActionSelectorBottomSheet.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingActionSelectorBottomSheet.kt @@ -10,9 +10,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle import com.tangem.core.ui.components.inputrow.InputRowDefault import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingInfoBottomSheet.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingInfoBottomSheet.kt index af0ceabcc0..ebc0b6ab11 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingInfoBottomSheet.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/bottomsheet/StakingInfoBottomSheet.kt @@ -9,9 +9,9 @@ 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.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetTitle +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetTitle import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index f43c34f8ac..e0fe076fab 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -1,5 +1,3 @@ package com.tangem.features.swap -interface SwapFeatureToggles { - val isPromoStoriesEnabled: Boolean -} \ No newline at end of file +interface SwapFeatureToggles \ No newline at end of file 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 98c167b26c..a4336b8eae 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 @@ -137,6 +137,10 @@ data class TxFee( val gasBudget: Long, val gasPrice: Long, ) : Params() + + data class Hedera( + val additionalHBARFee: BigDecimal, + ) : Params() } } 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 717a096159..29e7ee6c0d 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 @@ -849,6 +849,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( gasBudget = feeParams.gasBudget, ) } + Hedera, + HederaTestnet, + -> { + val feeParams = requireNotNull(fee.params as? TxFee.Params.Hedera) + Fee.Hedera(amount = feeAmount, additionalHBARFee = feeParams.additionalHBARFee) + } // endregion // region Blockchains with common fees or EVM-like fees Unknown, @@ -929,8 +935,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( ShibariumTestnet, Algorand, AlgorandTestnet, - Hedera, - HederaTestnet, Aurora, AuroraTestnet, Areon, @@ -991,6 +995,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ApeChain, ApeChainTestnet, Scroll, ScrollTestnet, ZkLinkNova, ZkLinkNovaTestnet, + Pepecoin, PepecoinTestnet, -> Fee.Common(feeAmount) // endregion } @@ -1899,6 +1904,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( gasPrice = fee.gasPrice, gasBudget = fee.gasBudget, ) + is Fee.Hedera -> TxFee.Params.Hedera( + additionalHBARFee = fee.additionalHBARFee, + ) is Fee.Aptos, is Fee.Bitcoin, is Fee.CardanoToken, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index cd341751d8..c202111fb6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -1,11 +1,5 @@ package com.tangem.feature.swap -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.swap.SwapFeatureToggles -internal class DefaultSwapFeatureToggles( - private val featureToggles: FeatureTogglesManager, -) : SwapFeatureToggles { - override val isPromoStoriesEnabled: Boolean - get() = featureToggles.isFeatureEnabled("SWAP_STORIES_ENABLED") -} \ No newline at end of file +internal class DefaultSwapFeatureToggles : SwapFeatureToggles \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 68d9795026..0f2e700cd8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -117,6 +117,7 @@ sealed class SwapEvents( "Error Code" to errorCode.toString(), ), ) + // TODO parameters // region Promo activity data class ChangellyActivity( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt index 5b732e9051..990f8c8ec9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt @@ -1,6 +1,5 @@ package com.tangem.feature.swap.di -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.feature.swap.DefaultSwapComponent import com.tangem.feature.swap.DefaultSwapFeatureToggles import com.tangem.features.swap.SwapComponent @@ -18,8 +17,8 @@ internal object SwapFeatureModule { @Provides @Singleton - fun provideSwapFeatureToggles(featureToggles: FeatureTogglesManager): SwapFeatureToggles { - return DefaultSwapFeatureToggles(featureToggles) + fun provideSwapFeatureToggles(): SwapFeatureToggles { + return DefaultSwapFeatureToggles() } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 1728183620..ea78698ede 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -9,6 +9,7 @@ import arrow.core.getOrElse import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull +import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic @@ -55,7 +56,6 @@ import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation import com.tangem.features.swap.SwapComponent -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import com.tangem.utils.isNullOrZero @@ -82,6 +82,7 @@ internal class SwapModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val blockchainInteractor: BlockchainInteractor, private val analyticsEventHandler: AnalyticsEventHandler, + private val analyticsErrorEventHandler: AnalyticsErrorHandler, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase, private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, @@ -94,7 +95,6 @@ internal class SwapModel @Inject constructor( private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, - private val featureToggles: SwapFeatureToggles, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, swapInteractorFactory: SwapInteractor.Factory, private val urlOpener: UrlOpener, @@ -164,10 +164,8 @@ internal class SwapModel @Inject constructor( init { modelScope.launch { - if (featureToggles.isPromoStoriesEnabled) { - initStories() - swapRouter.openScreen(SwapNavScreen.PromoStories) - } + initStories() + swapRouter.openScreen(SwapNavScreen.PromoStories) } modelScope.launch(dispatchers.io) { val fromStatus = getCryptoCurrencyStatusUseCase(userWalletId, initialCurrencyFrom.id).getOrNull() @@ -483,7 +481,7 @@ internal class SwapModel @Inject constructor( val receiveToken = dataState.toCryptoCurrency?.currency?.let { "${it.network.backendId}:${it.symbol}" } - analyticsEventHandler.send( + analyticsErrorEventHandler.sendErrorEvent( SwapEvents.NoticeProviderError( sendToken = "${initialCurrencyFrom.network.backendId}:${initialCurrencyFrom.symbol}", receiveToken = receiveToken ?: "", diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt index 13dc36fbac..836783751f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.rows.SelectorRowItem import com.tangem.core.ui.extensions.* diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt index e8078c5bee..1b45b356a8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 5f26b9b233..191d380edf 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { /** Domain modules */ implementation(projects.domain.appTheme) implementation(projects.domain.appTheme.models) + implementation(projects.domain.card) implementation(projects.domain.feedback) /** Other libraries */ diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt index e2d3b7e6a3..3777d8a26a 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/environments/viewmodels/EnvironmentsTogglesViewModel.kt @@ -6,6 +6,7 @@ import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.config.managers.MutableApiConfigsManager +import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.feature.tester.impl.BuildConfig import com.tangem.feature.tester.impl.R import com.tangem.feature.tester.presentation.environments.state.EnvironmentTogglesScreenUM @@ -28,6 +29,7 @@ import javax.inject.Inject @HiltViewModel internal class EnvironmentsTogglesViewModel @Inject constructor( apiConfigsManager: ApiConfigsManager, + private val cardSdkConfigRepository: CardSdkConfigRepository, ) : ViewModel() { /** Current ui state */ @@ -92,7 +94,24 @@ internal class EnvironmentsTogglesViewModel @Inject constructor( private fun onToggleValueChange(id: String, name: String) { viewModelScope.launch { - mutableApiConfigsManager.changeEnvironment(id = id, environment = ApiEnvironment.valueOf(name)) + val environment = ApiEnvironment.valueOf(name) + + handleIfTangemCardSdkConfig(id = id, environment = environment) + + mutableApiConfigsManager.changeEnvironment(id = id, environment = environment) + } + } + + /** Special logic for [ApiConfig.ID.TangemCardSdk] */ + private fun handleIfTangemCardSdkConfig(id: String, environment: ApiEnvironment) { + if (id == ApiConfig.ID.TangemCardSdk.name) { + cardSdkConfigRepository.setTangemApiProdEnvFlag( + flag = when (environment) { + ApiEnvironment.PROD -> true + ApiEnvironment.DEV -> false + else -> false + }, + ) } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 01d0aec7ba..7ce2014bc1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails import androidx.compose.ui.graphics.Color import androidx.paging.PagingData import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType @@ -27,7 +28,7 @@ internal object TokenDetailsPreviewData { onBackClick = {}, tokenDetailsAppBarMenuConfig = TokenDetailsAppBarMenuConfig( persistentListOf( - TokenDetailsAppBarMenuConfig.MenuItem( + TangemDropdownMenuItem( title = TextReference.Res(id = R.string.token_details_hide_token), textColorProvider = { TangemTheme.colors.text.warning }, onClick = { }, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index c716c21445..eeaaa1174a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -1,6 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model -import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel +import com.tangem.common.ui.bottomsheet.receive.AddressModel import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 6e167e2833..6ca825bee9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -17,8 +17,8 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel -import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels +import com.tangem.common.ui.bottomsheet.receive.AddressModel +import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsAppBarMenuConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsAppBarMenuConfig.kt index 4926ba0b4a..c5299ec1d5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsAppBarMenuConfig.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsAppBarMenuConfig.kt @@ -1,14 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import kotlinx.collections.immutable.ImmutableList -internal data class TokenDetailsAppBarMenuConfig(val items: ImmutableList) { - data class MenuItem( - val title: TextReference, - val textColorProvider: @Composable () -> Color, - val onClick: () -> Unit, - ) -} \ No newline at end of file +internal data class TokenDetailsAppBarMenuConfig(val items: ImmutableList) \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsBalanceSelectStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsBalanceSelectStateConverter.kt index 3425cedb3d..c9a852d86d 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsBalanceSelectStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsBalanceSelectStateConverter.kt @@ -5,10 +5,10 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.getBalance -import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import java.math.BigDecimal @@ -30,10 +30,10 @@ internal class TokenDetailsBalanceSelectStateConverter( } val yieldBalance = cryptoCurrencyStatus.value.yieldBalance as? YieldBalance.Data - val stakingCryptoAmount = yieldBalance?.getTotalWithRewardsStakingBalance() + val stakingCryptoAmount = yieldBalance?.getTotalWithRewardsStakingBalance( + cryptoCurrencyStatus.currency.network.id.value, + ) val stakingFiatAmount = stakingCryptoAmount?.let { cryptoCurrencyStatus.value.fiatRate?.multiply(it) } - val includeStakingTotalBalance = - BlockchainUtils.isIncludeStakingTotalBalance(cryptoCurrencyStatus.currency.network.id.value) copy( tokenBalanceBlockState = if (tokenBalanceBlockState is TokenDetailsBalanceBlockState.Content) { tokenBalanceBlockState.copy( @@ -43,13 +43,11 @@ internal class TokenDetailsBalanceSelectStateConverter( stakingFiatAmount = stakingFiatAmount, selectedBalanceType = value.type, appCurrency = appCurrencyProvider(), - includeStaking = includeStakingTotalBalance, ), displayCryptoBalance = formatCryptoAmount( status = cryptoCurrencyStatus, stakingCryptoAmount = stakingCryptoAmount, selectedBalanceType = value.type, - includeStaking = includeStakingTotalBalance, ), ) } else { @@ -64,9 +62,8 @@ internal class TokenDetailsBalanceSelectStateConverter( stakingFiatAmount: BigDecimal?, selectedBalanceType: BalanceType, appCurrency: AppCurrency, - includeStaking: Boolean, ): String { - val fiatAmount = status.fiatAmount?.getBalance(selectedBalanceType, stakingFiatAmount, includeStaking) + val fiatAmount = status.fiatAmount?.getBalance(selectedBalanceType, stakingFiatAmount) return fiatAmount.format { fiat( @@ -80,9 +77,8 @@ internal class TokenDetailsBalanceSelectStateConverter( status: CryptoCurrencyStatus, stakingCryptoAmount: BigDecimal?, selectedBalanceType: BalanceType, - includeStaking: Boolean, ): String { - val amount = status.value.amount?.getBalance(selectedBalanceType, stakingCryptoAmount, includeStaking) + val amount = status.value.amount?.getBalance(selectedBalanceType, stakingCryptoAmount) return amount.format { crypto(status.currency) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt index 12a86802f4..04fff27647 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -10,14 +10,16 @@ import com.tangem.core.ui.format.bigdecimal.* import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.StatusSource import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.domain.tokens.error.CurrencyStatusError import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents -import com.tangem.feature.tokendetails.presentation.tokendetails.state.* +import com.tangem.feature.tokendetails.presentation.tokendetails.state.BalanceType +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsTxHistoryTransactionStateConverter import com.tangem.feature.tokendetails.presentation.tokendetails.state.utils.getBalance -import com.tangem.lib.crypto.BlockchainUtils.isIncludeStakingTotalBalance import com.tangem.utils.Provider import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.converter.Converter @@ -84,10 +86,12 @@ internal class TokenDetailsLoadedBalanceConverter( currentState: TokenDetailsBalanceBlockState, status: CryptoCurrencyStatus, ): TokenDetailsBalanceBlockState { - val stakingCryptoAmount = (status.value.yieldBalance as? YieldBalance.Data)?.getTotalWithRewardsStakingBalance() + val stakingCryptoAmount = + (status.value.yieldBalance as? YieldBalance.Data)?.getTotalWithRewardsStakingBalance( + status.currency.network.id.value, + ) val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } val isBalanceSelectorEnabled = !stakingCryptoAmount.isNullOrZero() - val includeStakingTotalBalance = isIncludeStakingTotalBalance(status.currency.network.id.value) return when (status.value) { is CryptoCurrencyStatus.NoQuote, is CryptoCurrencyStatus.Loaded, @@ -102,13 +106,11 @@ internal class TokenDetailsLoadedBalanceConverter( stakingFiatAmount, currentState.selectedBalanceType, appCurrencyProvider(), - includeStakingTotalBalance, ), displayCryptoBalance = formatCryptoAmount( status, stakingCryptoAmount, currentState.selectedBalanceType, - includeStakingTotalBalance, ), balanceSegmentedButtonConfig = currentState.balanceSegmentedButtonConfig, onBalanceSelect = clickIntents::onBalanceSelect, @@ -192,10 +194,9 @@ internal class TokenDetailsLoadedBalanceConverter( stakingFiatAmount: BigDecimal?, selectedBalanceType: BalanceType, appCurrency: AppCurrency, - includeStaking: Boolean, ): String { val fiatAmount = status.fiatAmount ?: return DASH_SIGN - val totalAmount = fiatAmount.getBalance(selectedBalanceType, stakingFiatAmount, includeStaking) + val totalAmount = fiatAmount.getBalance(selectedBalanceType, stakingFiatAmount) return totalAmount.format { fiat( @@ -209,10 +210,9 @@ internal class TokenDetailsLoadedBalanceConverter( status: CryptoCurrencyStatus, stakingCryptoAmount: BigDecimal?, selectedBalanceType: BalanceType, - includeStaking: Boolean, ): String { val amount = status.value.amount ?: return DASH_SIGN - val totalAmount = amount.getBalance(selectedBalanceType, stakingCryptoAmount, includeStaking) + val totalAmount = amount.getBalance(selectedBalanceType, stakingCryptoAmount) return totalAmount.format { crypto(status.currency) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt index d4f23daa2d..124447b0a9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -2,6 +2,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import arrow.core.getOrElse import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference @@ -14,9 +15,9 @@ import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.* import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton -import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.features.tokendetails.impl.R import com.tangem.lib.crypto.BlockchainUtils.isBitcoin import com.tangem.utils.converter.Converter @@ -86,7 +87,7 @@ internal class TokenDetailsSkeletonStateConverter( private fun createMenu(cryptoCurrency: CryptoCurrency): TokenDetailsAppBarMenuConfig = TokenDetailsAppBarMenuConfig( items = buildList { addGenerateXPubMenuItem(cryptoCurrency) - TokenDetailsAppBarMenuConfig.MenuItem( + TangemDropdownMenuItem( title = TextReference.Res(id = R.string.token_details_hide_token), textColorProvider = { TangemTheme.colors.text.warning }, onClick = clickIntents::onHideClick, @@ -94,9 +95,7 @@ internal class TokenDetailsSkeletonStateConverter( }.toImmutableList(), ) - private fun MutableList.addGenerateXPubMenuItem( - cryptoCurrency: CryptoCurrency, - ) { + private fun MutableList.addGenerateXPubMenuItem(cryptoCurrency: CryptoCurrency) { val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return val isBitcoin = isBitcoin(cryptoCurrency.network.id.value) @@ -107,7 +106,7 @@ internal class TokenDetailsSkeletonStateConverter( if (isBitcoin && hasDerivations) { add( - TokenDetailsAppBarMenuConfig.MenuItem( + TangemDropdownMenuItem( title = resourceReference(R.string.token_details_generate_xpub), textColorProvider = { TangemTheme.colors.text.primary1 }, onClick = clickIntents::onGenerateExtendedKey, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt index ef4a99cb22..0f402c7631 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStakingInfoConverter.kt @@ -13,6 +13,8 @@ import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingEntryInfo import com.tangem.domain.staking.model.stakekit.RewardBlockType import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.utils.getRewardStakingBalance +import com.tangem.domain.staking.utils.getTotalStakingBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents import com.tangem.feature.tokendetails.presentation.tokendetails.state.IconState @@ -55,7 +57,7 @@ internal class TokenDetailsStakingInfoConverter( private fun getStakingInfoBlock(status: CryptoCurrencyStatus, state: TokenDetailsState): StakingBlockUM? { val yieldBalance = status.value.yieldBalance as? YieldBalance.Data - val stakingCryptoAmount = yieldBalance?.getTotalStakingBalance() + val stakingCryptoAmount = yieldBalance?.getTotalStakingBalance(status.currency.network.id.value) val pendingBalances = yieldBalance?.balance?.items ?: emptyList() val iconState = state.tokenInfoBlockState.iconState @@ -155,7 +157,11 @@ internal class TokenDetailsStakingInfoConverter( } return when (rewardBlockType) { - RewardBlockType.Rewards -> resourceReference( + RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim) + RewardBlockType.RewardUnavailable -> TextReference.EMPTY + RewardBlockType.RewardsRequirementsError, + RewardBlockType.Rewards, + -> resourceReference( R.string.staking_details_rewards_to_claim, wrappedList( stakingRewardAmount.format { @@ -166,8 +172,6 @@ internal class TokenDetailsStakingInfoConverter( }, ), ) - RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim) - RewardBlockType.RewardUnavailable -> TextReference.EMPTY } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index d360a8883f..04b691db1a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -4,9 +4,9 @@ import androidx.paging.PagingData import arrow.core.Either import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels +import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.transactions.state.TransactionState import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.TextReference @@ -266,10 +266,12 @@ internal class TokenDetailsStateFactory( isShown = true, onDismissRequest = clickIntents::onDismissBottomSheet, content = TokenReceiveBottomSheetConfig( - name = currency.name, - symbol = currency.symbol, - network = currency.network.name, - addresses = networkAddress.availableAddresses.mapToAddressModels(currency).toImmutableList(), + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = networkAddress, showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, onCopyClick = onCopyClick, onShareClick = onShareClick, @@ -287,7 +289,12 @@ internal class TokenDetailsStateFactory( isShown = true, onDismissRequest = clickIntents::onDismissBottomSheet, content = ChooseAddressBottomSheetConfig( - addressModels = networkAddress.availableAddresses.mapToAddressModels(currency).toImmutableList(), + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = networkAddress, onClick = clickIntents::onAddressTypeSelected, ), ), @@ -376,13 +383,13 @@ internal class TokenDetailsStateFactory( return copy( items = buildList { if (isSupported && hasDerivations) { - TokenDetailsAppBarMenuConfig.MenuItem( + TangemDropdownMenuItem( title = resourceReference(R.string.token_details_generate_xpub), textColorProvider = { TangemTheme.colors.text.primary1 }, onClick = clickIntents::onGenerateExtendedKey, ).let(::add) } - TokenDetailsAppBarMenuConfig.MenuItem( + TangemDropdownMenuItem( title = TextReference.Res(id = R.string.token_details_hide_token), textColorProvider = { TangemTheme.colors.text.warning }, onClick = clickIntents::onHideClick, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/BalanceUtils.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/BalanceUtils.kt index 25b4f3ecc1..fe714ff2ca 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/BalanceUtils.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/utils/BalanceUtils.kt @@ -3,12 +3,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.utils import com.tangem.feature.tokendetails.presentation.tokendetails.state.BalanceType import java.math.BigDecimal -internal fun BigDecimal.getBalance( - selectedBalanceType: BalanceType, - stakingAmount: BigDecimal?, - includeStaking: Boolean, -): BigDecimal { - return if (selectedBalanceType == BalanceType.ALL && stakingAmount != null && includeStaking) { +internal fun BigDecimal.getBalance(selectedBalanceType: BalanceType, stakingAmount: BigDecimal?): BigDecimal { + return if (selectedBalanceType == BalanceType.ALL && stakingAmount != null) { this.plus(stakingAmount) } else { this diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index f315753e17..03b631022d 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -18,10 +18,10 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.expressTransactionsItems -import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheet -import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig +import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheet +import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.marketprice.MarketPriceBlock import com.tangem.core.ui.components.marketprice.MarketPriceBlockState diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsTopAppBar.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsTopAppBar.kt index 3ea60b1140..648e7aee48 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsTopAppBar.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsTopAppBar.kt @@ -3,8 +3,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.padding import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -14,14 +12,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.util.fastForEach -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsAppBarMenuConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarConfig import com.tangem.features.tokendetails.impl.R @@ -58,10 +54,9 @@ internal fun TokenDetailsTopAppBar(config: TokenDetailsTopAppBarConfig) { expanded = showDropdownMenu, modifier = Modifier.background(TangemTheme.colors.background.primary), onDismissRequest = { showDropdownMenu = false }, - offset = DpOffset(x = TangemTheme.dimens.spacing20, y = TangemTheme.dimens.spacing10.times(-1)), content = { config.tokenDetailsAppBarMenuConfig?.items?.fastForEach { - AppBarDropdownItem( + TangemDropdownItem( item = it, dismissParent = { showDropdownMenu = false }, ) @@ -78,42 +73,6 @@ internal fun TokenDetailsTopAppBar(config: TokenDetailsTopAppBarConfig) { ) } -@Suppress("ComposableEventParameterNaming") -@Composable -private fun AppBarDropdownItem( - item: TokenDetailsAppBarMenuConfig.MenuItem, - dismissParent: () -> Unit, - modifier: Modifier = Modifier, -) { - Text( - modifier = modifier - .clickable { - dismissParent() - item.onClick() - } - .padding(vertical = TangemTheme.dimens.spacing8, horizontal = TangemTheme.dimens.spacing16), - text = item.title.resolveReference(), - style = TangemTheme.typography.body1.copy(color = item.textColorProvider()), - ) -} - -@Preview -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_TokenDetailsAppBarDropdownItem() { - TangemThemePreview { - AppBarDropdownItem( - modifier = Modifier.background(TangemTheme.colors.background.primary), - dismissParent = {}, - item = TokenDetailsAppBarMenuConfig.MenuItem( - title = TextReference.Res(id = R.string.token_details_hide_token), - textColorProvider = { TangemTheme.colors.text.warning }, - onClick = { }, - ), - ) - } -} - @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheet.kt index 0bf0416539..360c82a53a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheet.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheet.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.OnrampStatusBottomSheetContent import com.tangem.common.ui.expressStatus.state.* -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt index 2f4aba1080..c04a88819e 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt @@ -6,12 +6,14 @@ import com.tangem.domain.txhistory.models.TxHistoryItem import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter import com.tangem.features.txhistory.entity.TxHistoryUM import com.tangem.pagination.Batch +import com.tangem.pagination.PaginationStatus import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.mapLatest import java.util.UUID @@ -23,6 +25,8 @@ internal class TxHistoryUiManager( @OptIn(ExperimentalCoroutinesApi::class) val items: Flow> = state + // filter initial states, since we dont emit loading items as UI items + .filter { it.status !is PaginationStatus.None && it.status !is PaginationStatus.InitialLoading } .mapLatest { state -> state.uiBatches.asSequence() .flatMap { it.data } diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/analytics/Settings.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/analytics/Settings.kt index 99b401cfda..51c265394a 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/analytics/Settings.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/analytics/Settings.kt @@ -6,8 +6,7 @@ internal sealed class Settings( category: String = "Settings", event: String, params: Map = mapOf(), - error: Throwable? = null, -) : AnalyticsEvent(category, event, params, error) { +) : AnalyticsEvent(category, event, params) { data object ButtonCreateBackup : Settings(event = "Button - Create Backup") diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 52f23e9529..cc48296697 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -164,7 +164,7 @@ internal class WalletSettingsModel @Inject constructor( router.push( AppRoute.Onboarding( scanResponse = scanResponse, - mode = AppRoute.Onboarding.Mode.AddBackup, + mode = AppRoute.Onboarding.Mode.AddBackupWallet1, ), ) } else { diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt index 97d2d5800f..8621c8c0d1 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt @@ -5,7 +5,4 @@ package com.tangem.features.wallet.featuretoggles * [REDACTED_AUTHOR] */ -interface WalletFeatureToggles { - - val isMainActionButtonsEnabled: Boolean -} \ No newline at end of file +interface WalletFeatureToggles \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index f786056d52..da4e888dfe 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -66,30 +66,34 @@ dependencies { implementation(projects.libs.blockchainSdk) /** Domain modules */ - implementation(projects.domain.card) - implementation(projects.domain.demo) - implementation(projects.domain.legacy) - implementation(projects.domain.models) - implementation(projects.domain.settings) - implementation(projects.domain.tokens) - implementation(projects.domain.tokens.models) - implementation(projects.domain.txhistory) - implementation(projects.domain.txhistory.models) - implementation(projects.domain.wallets) - implementation(projects.domain.wallets.models) + implementation(projects.domain.analytics) implementation(projects.domain.appCurrency) implementation(projects.domain.appCurrency.models) implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) - implementation(projects.domain.analytics) - implementation(projects.domain.visa) - implementation(projects.domain.staking.models) - implementation(projects.domain.markets.models) + implementation(projects.domain.card) + implementation(projects.domain.demo) implementation(projects.domain.feedback) - implementation(projects.domain.onramp.models) + implementation(projects.domain.legacy) + implementation(projects.domain.markets.models) + implementation(projects.domain.models) + implementation(projects.domain.networks) + implementation(projects.domain.nft) + implementation(projects.domain.nft.models) implementation(projects.domain.onramp) + implementation(projects.domain.onramp.models) implementation(projects.domain.promo) implementation(projects.domain.promo.models) + implementation(projects.domain.settings) + implementation(projects.domain.staking) + implementation(projects.domain.staking.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.txhistory) + implementation(projects.domain.txhistory.models) + implementation(projects.domain.visa) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) //TODO: Create api/impl modules for onboarding [REDACTED_JIRA] implementation(projects.features.onboarding) @@ -108,6 +112,7 @@ dependencies { implementation(projects.features.wallet.api) implementation(projects.features.walletSettings.api) implementation(projects.features.biometry.api) + implementation(projects.features.nft.api) /** Common modules */ implementation(projects.common) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 8f748fd22b..5359f89098 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -10,9 +10,13 @@ import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.nft.FetchNFTCollectionsUseCase import com.tangem.domain.settings.* +import com.tangem.domain.tokens.FetchCurrencyStatusUseCase import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents @@ -38,9 +42,9 @@ import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.biometry.BiometryFeatureToggles import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.features.pushnotifications.api.utils.getPushPermissionOrNull -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.coroutines.* +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -73,9 +77,11 @@ internal class WalletModel @Inject constructor( private val walletImageResolver: WalletImageResolver, private val tokenListStore: MultiWalletTokenListStore, private val onrampStatusFactory: OnrampStatusFactory, - private val walletFeatureToggles: WalletFeatureToggles, private val biometryFeatureToggles: BiometryFeatureToggles, private val analyticsEventsHandler: AnalyticsEventHandler, + private val fetchNFTCollectionsUseCase: FetchNFTCollectionsUseCase, + private val walletsRepository: WalletsRepository, + private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -86,6 +92,7 @@ internal class WalletModel @Inject constructor( private val walletsUpdateJobHolder = JobHolder() private val refreshWalletJobHolder = JobHolder() private val expressStatusJobHolder = JobHolder() + private val walletsNFTsUpdateJobHolder = JobHolder() private var needToRefreshWallet = false private var expressTxStatusTaskScheduler = SingleTaskScheduler() @@ -103,6 +110,7 @@ internal class WalletModel @Inject constructor( subscribeToScreenBackgroundState() subscribeOnPushNotificationsPermission() subscribeOnExpressTransactionsUpdates() + subscribeOnNFTUpdates() clickIntents.initialize(innerWalletRouter, modelScope) } @@ -160,7 +168,10 @@ internal class WalletModel @Inject constructor( .conflate() .distinctUntilChanged() .map { - walletsUpdateActionResolver.resolve(wallets = it, currentState = stateHolder.value) + walletsUpdateActionResolver.resolve( + wallets = it, + currentState = stateHolder.value, + ) } .onEach(::updateWallets) .flowOn(dispatchers.main) @@ -256,6 +267,29 @@ internal class WalletModel @Inject constructor( }.saveIn(expressStatusJobHolder) } + @OptIn(ExperimentalCoroutinesApi::class) + private fun subscribeOnNFTUpdates() { + getWalletsUseCase() + .conflate() + .distinctUntilChanged() + .flatMapLatest { wallets -> + wallets + .map { wallet -> + walletsRepository.nftEnabledStatus(wallet.walletId) + .distinctUntilChanged() + .onEach { nftEnabled -> + if (nftEnabled) { + fetchNFTCollectionsUseCase.invoke(wallet.walletId) + } + } + } + .merge() + } + .flowOn(dispatchers.main) + .launchIn(modelScope) + .saveIn(walletsNFTsUpdateJobHolder) + } + private fun needToRefreshTimer() { modelScope.launch { delay(REFRESH_WALLET_BACKGROUND_TIMER_MILLIS) @@ -313,7 +347,6 @@ internal class WalletModel @Inject constructor( wallets = action.wallets, clickIntents = clickIntents, walletImageResolver = walletImageResolver, - walletFeatureToggles = walletFeatureToggles, ), ) @@ -323,6 +356,10 @@ internal class WalletModel @Inject constructor( coroutineScope = modelScope, ) + if (action.selectedWallet.scanResponse.cardTypesResolver.isSingleWallet()) { + fetchCurrencyStatusUseCase(userWalletId = action.selectedWallet.walletId) + } + if (action.wallets.size > 1 && isWalletsScrollPreviewEnabled()) { withContext(dispatchers.io) { delay(timeMillis = 1_800) } @@ -354,7 +391,6 @@ internal class WalletModel @Inject constructor( newUserWallet = action.selectedWallet, clickIntents = clickIntents, walletImageResolver = walletImageResolver, - walletFeatureToggles = walletFeatureToggles, ), ) } @@ -371,7 +407,6 @@ internal class WalletModel @Inject constructor( userWallet = action.selectedWallet, clickIntents = clickIntents, walletImageResolver = walletImageResolver, - walletFeatureToggles = walletFeatureToggles, ), ) @@ -382,7 +417,7 @@ internal class WalletModel @Inject constructor( } } - private suspend fun deleteWallet(action: WalletsUpdateActionResolver.Action.DeleteWallet) { + private fun deleteWallet(action: WalletsUpdateActionResolver.Action.DeleteWallet) { walletScreenContentLoader.cancel(action.deletedWalletId) tokenListStore.remove(action.deletedWalletId) @@ -427,7 +462,6 @@ internal class WalletModel @Inject constructor( unlockedWallets = action.unlockedWallets, clickIntents = clickIntents, walletImageResolver = walletImageResolver, - walletFeatureToggles = walletFeatureToggles, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt index 5594553aab..35cf3a44cd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletsUpdateActionResolver.kt @@ -231,7 +231,10 @@ internal class WalletsUpdateActionResolver @Inject constructor( * @property prevWalletId previous selected wallet id * @property selectedWallet selected wallet */ - data class ReinitializeWallet(val prevWalletId: UserWalletId, val selectedWallet: UserWallet) : Action() { + data class ReinitializeWallet( + val prevWalletId: UserWalletId, + val selectedWallet: UserWallet, + ) : Action() { override fun toString(): String { return "ReinitializeWallet(prevWalletId = $prevWalletId, selectedWallet = ${selectedWallet.walletId})" 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 4a0281d436..8fe27fb45f 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 @@ -2,29 +2,19 @@ package com.tangem.feature.wallet.child.wallet.model.intents import arrow.core.getOrElse import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels -import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.tokens.model.CryptoCurrencyStatus -import com.tangem.domain.tokens.model.Network import com.tangem.domain.visa.GetVisaCurrencyUseCase import com.tangem.domain.visa.GetVisaTxDetailsUseCase -import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.BalancesAndLimitsBottomSheetConverter import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.VisaTxDetailsBottomSheetConverter import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject internal interface VisaWalletIntents { - fun onDepositClick() - fun onBalancesAndLimitsClick() fun onVisaTransactionClick(id: String) @@ -36,7 +26,6 @@ internal interface VisaWalletIntents { internal class VisaWalletIntentsImplementor @Inject constructor( private val stateController: WalletStateController, private val eventSender: WalletEventSender, - private val getCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase, private val getVisaCurrencyUseCase: GetVisaCurrencyUseCase, private val getVisaTxDetailsUseCase: GetVisaTxDetailsUseCase, private val dispatchers: CoroutineDispatcherProvider, @@ -46,38 +35,6 @@ internal class VisaWalletIntentsImplementor @Inject constructor( BalancesAndLimitsBottomSheetConverter(eventSender) } - override fun onDepositClick() { - val userWalletId = stateController.getSelectedWalletId() - - modelScope.launch(dispatchers.main) { - val currencyStatus = getPrimaryCurrencyStatus(userWalletId) ?: return@launch - - createReceiveBottomSheetContent(currencyStatus)?.let { content -> - stateController.showBottomSheet(content) - } - } - } - - private fun createReceiveBottomSheetContent(currencyStatus: CryptoCurrencyStatus): TangemBottomSheetConfigContent? { - val currency = currencyStatus.currency - val addresses = currencyStatus.value.networkAddress?.availableAddresses - - if (addresses == null) { - Timber.e("Addresses should not be null") - return null - } - - return TokenReceiveBottomSheetConfig( - name = currency.name, - symbol = currency.symbol, - network = currency.network.name, - addresses = addresses.mapToAddressModels(currency).toImmutableList(), - showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, - onCopyClick = { /* no-op */ }, - onShareClick = { /* no-op */ }, - ) - } - override fun onBalancesAndLimitsClick() { modelScope.launch(dispatchers.main) { val userWalletId = stateController.getSelectedWalletId() @@ -95,14 +52,6 @@ internal class VisaWalletIntentsImplementor @Inject constructor( } } - private suspend fun getPrimaryCurrencyStatus(userWalletId: UserWalletId): CryptoCurrencyStatus? { - return getCurrencyStatusUseCase(userWalletId) - .getOrElse { - Timber.e("Failed to get primary currency $it") - null - } - } - override fun onVisaTransactionClick(id: String) { modelScope.launch(dispatchers.main) { val userWalletId = stateController.getSelectedWalletId() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt index e84eccf060..ee324b252e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletClickIntents.kt @@ -5,6 +5,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.extenstions.unwrap import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.nft.FetchNFTCollectionsUseCase import com.tangem.domain.onramp.FetchHotCryptoUseCase import com.tangem.domain.settings.NeverToShowWalletsScrollPreview import com.tangem.domain.tokens.FetchCardTokenListUseCase @@ -14,6 +15,7 @@ import com.tangem.domain.tokens.FetchTokenListUseCase.RefreshMode import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase import com.tangem.feature.wallet.presentation.router.InnerWalletRouter +import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase import com.tangem.feature.wallet.presentation.wallet.domain.unwrap import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController @@ -50,6 +52,8 @@ internal class WalletClickIntents @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val onrampFeatureToggles: OnrampFeatureToggles, private val fetchHotCryptoUseCase: FetchHotCryptoUseCase, + private val fetchNFTCollectionsUseCase: FetchNFTCollectionsUseCase, + private val isWalletNFTEnabledSyncUseCase: IsWalletNFTEnabledSyncUseCase, ) : BaseWalletClickIntents(), WalletCardClickIntents by walletCardClickIntentsImplementor, WalletWarningsClickIntents by warningsClickIntentsImplementer, @@ -137,8 +141,10 @@ internal class WalletClickIntents @Inject constructor( async { rampStateManager.fetchSellServiceData() }.let(::add) - if (onrampFeatureToggles.isHotTokensEnabled) { - async { fetchHotCryptoUseCase() }.let(::add) + async { fetchHotCryptoUseCase() }.let(::add) + + if (isWalletNFTEnabledSyncUseCase.invoke(userWallet.walletId)) { + async { fetchNFTCollectionsUseCase(userWalletId = userWallet.walletId) }.let(::add) } } .awaitAll() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 90fdd5b760..79fd4f1a7f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -12,6 +12,7 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory import com.tangem.feature.wallet.presentation.wallet.domain.unwrap @@ -55,6 +56,8 @@ internal interface WalletContentClickIntents { fun onConfirmDisposeExpressStatus() fun onDisposeExpressStatus() + + fun onNFTClick(userWalletId: UserWalletId) } @Suppress("LongParameterList") @@ -235,4 +238,8 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } stateHolder.update(CloseBottomSheetTransformer(userWalletId)) } + + override fun onNFTClick(userWalletId: UserWalletId) { + router.openNFTCollectionsScreen(userWalletId) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index d96b0968ad..9068f9a7f1 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 @@ -11,10 +11,10 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.navigation.share.ShareManager import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.mapToAddressModels +import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig +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.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.WrappedList @@ -56,7 +56,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensLis import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.features.onramp.OnrampFeatureToggles -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -125,7 +124,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private val appRouter: AppRouter, private val rampStateManager: RampStateManager, private val onrampFeatureToggles: OnrampFeatureToggles, - private val swapFeatureToggles: SwapFeatureToggles, ) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { override fun onSendClick( @@ -171,7 +169,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( stateHolder.showBottomSheet( createReceiveBottomSheetContent( currency = cryptoCurrencyStatus.currency, - addresses = cryptoCurrencyStatus.value.networkAddress?.availableAddresses ?: return, + addresses = cryptoCurrencyStatus.value.networkAddress ?: return, ), userWalletId, ) @@ -191,13 +189,15 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private fun createReceiveBottomSheetContent( currency: CryptoCurrency, - addresses: Set, + addresses: NetworkAddress, ): TangemBottomSheetConfigContent { return TokenReceiveBottomSheetConfig( - name = currency.name, - symbol = currency.symbol, - network = currency.network.name, - addresses = addresses.mapToAddressModels(currency).toImmutableList(), + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = addresses, showMemoDisclaimer = currency.network.transactionExtrasType != Network.TransactionExtrasType.NONE, onCopyClick = { analyticsEventHandler.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(currency.symbol)) @@ -490,7 +490,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( when (val addresses = currencyStatus.value.networkAddress) { is NetworkAddress.Selectable -> { - showChooseAddressBottomSheet(userWalletId, addresses.availableAddresses, currencyStatus.currency) + showChooseAddressBottomSheet(userWalletId, addresses, currencyStatus.currency) } is NetworkAddress.Single -> { router.openUrl( @@ -508,12 +508,17 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private fun showChooseAddressBottomSheet( userWalletId: UserWalletId, - addresses: Set, + addresses: NetworkAddress, currency: CryptoCurrency, ) { stateHolder.showBottomSheet( ChooseAddressBottomSheetConfig( - addressModels = addresses.mapToAddressModels(currency).toImmutableList(), + asset = TokenReceiveBottomSheetConfig.Asset.Currency( + name = currency.name, + symbol = currency.symbol, + ), + network = currency.network, + networkAddress = addresses, onClick = { onAddressTypeSelected( userWalletId = userWalletId, @@ -632,9 +637,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } private suspend fun getSwapRoute(targetRoute: AppRoute): AppRoute { - val isSwapStoriesEnabled = swapFeatureToggles.isPromoStoriesEnabled val maybeSwapStories = getStoryContentUseCase.invokeSync(StoryContentIds.STORY_FIRST_TIME_SWAP.id) - val showSwapStories = maybeSwapStories.getOrNull() != null && isSwapStoriesEnabled + val showSwapStories = maybeSwapStories.getOrNull() != null return if (showSwapStories) { AppRoute.Stories( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index aab061ab81..419db01ebc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -12,6 +12,7 @@ import com.tangem.domain.card.SetCardWasScannedUseCase import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.promo.ShouldShowSwapPromoWalletUseCase import com.tangem.domain.redux.LegacyAction import com.tangem.domain.redux.ReduxStateHolder @@ -19,6 +20,7 @@ import com.tangem.domain.settings.NeverToSuggestRateAppUseCase import com.tangem.domain.settings.RemindToRateAppLaterUseCase import com.tangem.domain.tokens.FetchTokenListUseCase import com.tangem.domain.tokens.FetchTokenListUseCase.RefreshMode +import com.tangem.domain.tokens.TokensFeatureToggles import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.analytics.TokenSwapPromoAnalyticsEvent import com.tangem.domain.wallets.legacy.UserWalletsListManager.Lockable.UnlockType @@ -101,6 +103,8 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val seedPhraseNotificationUseCase: SeedPhraseNotificationUseCase, private val urlOpener: UrlOpener, + private val tokensFeatureToggles: TokensFeatureToggles, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, ) : BaseWalletClickIntents(), WalletWarningsClickIntents { override fun onAddBackupCardClick() { @@ -149,12 +153,21 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( ).fold( ifLeft = { Timber.e(it, "Failed to derive public keys") }, ifRight = { - fetchTokenListUseCase( - userWalletId = userWallet.walletId, - mode = RefreshMode.SKIP_CURRENCIES, - currencies = missedAddressCurrencies, - ).onLeft { - Timber.e("Unable to refresh token list: $it") + if (tokensFeatureToggles.isNetworksLoadingRefactoringEnabled) { + multiNetworkStatusFetcher( + params = MultiNetworkStatusFetcher.Params( + userWalletId = userWallet.walletId, + networks = missedAddressCurrencies.map(CryptoCurrency::network).toSet(), + ), + ) + } else { + fetchTokenListUseCase( + userWalletId = userWallet.walletId, + mode = RefreshMode.SKIP_CURRENCIES, + currencies = missedAddressCurrencies, + ).onLeft { + Timber.e("Unable to refresh token list: $it") + } } }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt index c3aba027cc..f283fadd39 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt @@ -1,13 +1,6 @@ package com.tangem.feature.wallet.featuretoggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import javax.inject.Inject -internal class DefaultWalletFeatureToggles @Inject constructor( - private val featureTogglesManager: FeatureTogglesManager, -) : WalletFeatureToggles { - - override val isMainActionButtonsEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "MAIN_ACTION_BUTTONS_ENABLED") -} \ No newline at end of file +internal class DefaultWalletFeatureToggles @Inject constructor() : WalletFeatureToggles \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt index 4f8cb9bdfd..08937391da 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewData.kt @@ -120,6 +120,13 @@ internal object WalletScreenPreviewData { ), bottomSheetConfig = null, tokensListState = textContentTokensState, + nftState = WalletNFTItemUM.Content( + previews = persistentListOf(WalletNFTItemUM.Content.CollectionPreview.Image("img1")), + collectionsCount = 1, + assetsCount = 3, + isFlickering = false, + onItemClick = { }, + ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/CryptoCurrencyToDraggableItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/CryptoCurrencyToDraggableItemConverter.kt index c7302dd54a..a8124c5b6a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/CryptoCurrencyToDraggableItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/CryptoCurrencyToDraggableItemConverter.kt @@ -8,11 +8,11 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.staking.model.stakekit.YieldBalance +import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getGroupHeaderId import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getTokenItemId -import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.orZero @@ -65,11 +65,8 @@ internal class CryptoCurrencyToDraggableItemConverter( private fun getFormattedFiatAmount(currency: CryptoCurrencyStatus, appCurrency: AppCurrency): String { val yieldBalance = currency.value.yieldBalance as? YieldBalance.Data val fiatRate = currency.value.fiatRate ?: BigDecimal.ZERO - val fiatYieldBalance = if (BlockchainUtils.isIncludeStakingTotalBalance(currency.currency.network.id.value)) { - yieldBalance?.getTotalWithRewardsStakingBalance()?.multiply(fiatRate).orZero() - } else { - BigDecimal.ZERO - } + val fiatYieldBalance = yieldBalance?.getTotalWithRewardsStakingBalance(currency.currency.network.id.value) + ?.multiply(fiatRate).orZero() val fiatAmount = currency.value.fiatAmount ?: return BigDecimalFormatConstants.EMPTY_BALANCE_SIGN return (fiatAmount + fiatYieldBalance).format { fiat(appCurrency.code, appCurrency.symbol) } 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 967c19ebed..f6305b826f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -54,7 +54,7 @@ internal class DefaultWalletRouter @Inject constructor( AppRoute.Onboarding( scanResponse = scanResponse, mode = if (continueBackup) { - AppRoute.Onboarding.Mode.AddBackup + AppRoute.Onboarding.Mode.AddBackupWallet1 } else { AppRoute.Onboarding.Mode.Onboarding }, @@ -115,4 +115,8 @@ internal class DefaultWalletRouter @Inject constructor( override fun openScanFailedDialog(onTryAgain: () -> Unit) { reduxStateHolder.dispatchDialogShow(StateDialog.ScanFailsDialog(StateDialog.ScanFailsSource.MAIN, onTryAgain)) } + + override fun openNFTCollectionsScreen(userWalletId: UserWalletId) { + router.push(AppRoute.NFTCollections(userWalletId)) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 9ec77cb66c..4e49fb9f70 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -56,4 +56,7 @@ internal interface InnerWalletRouter { /** Open scan failed dialog */ fun openScanFailedDialog(onTryAgain: () -> Unit) + + /** Open NFT collections screen */ + fun openNFTCollectionsScreen(userWalletId: UserWalletId) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt index c39efabf74..a07053ba04 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/WalletScreenAnalyticsEvent.kt @@ -10,8 +10,7 @@ sealed class WalletScreenAnalyticsEvent { sealed class Basic( event: String, params: Map = mapOf(), - error: Throwable? = null, - ) : AnalyticsEvent(category = "Basic", event = event, params = params, error = error) { + ) : AnalyticsEvent(category = "Basic", event = event, params = params) { class WalletToppedUp(userWalletId: UserWalletId, walletType: AnalyticsParam.WalletType) : Basic( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/IsWalletNFTEnabledSyncUseCase.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/IsWalletNFTEnabledSyncUseCase.kt new file mode 100644 index 0000000000..e931183ffe --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/IsWalletNFTEnabledSyncUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.features.nft.NFTFeatureToggles +import kotlinx.coroutines.flow.firstOrNull + +class IsWalletNFTEnabledSyncUseCase( + private val walletsRepository: WalletsRepository, + private val nftFeatureToggles: NFTFeatureToggles, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Boolean = if (nftFeatureToggles.isNFTEnabled) { + walletsRepository + .nftEnabledStatuses() + .firstOrNull() + ?.let { it[userWalletId] } + ?: false + } else { + false + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/ScanCardToUnlockWalletClickHandler.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/ScanCardToUnlockWalletClickHandler.kt index a487bd79bd..136f5c811d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/ScanCardToUnlockWalletClickHandler.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/ScanCardToUnlockWalletClickHandler.kt @@ -8,7 +8,6 @@ import com.tangem.common.core.TangemSdkError import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.wallets.builder.UserWalletBuilder -import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.SaveWalletUseCase import javax.inject.Inject @@ -16,7 +15,7 @@ import javax.inject.Inject internal class ScanCardToUnlockWalletClickHandler @Inject constructor( private val scanCardProcessor: ScanCardProcessor, private val saveWalletUseCase: SaveWalletUseCase, - private val generateWalletNameUseCase: GenerateWalletNameUseCase, + private val userWalletBuilderFactory: UserWalletBuilder.Factory, ) { private var scanFailsCounter = 0 @@ -34,7 +33,7 @@ internal class ScanCardToUnlockWalletClickHandler @Inject constructor( scanFailsCounter = 0 // If card's public key is null then user wallet will be null - val scannedWallet = UserWalletBuilder(result.data, generateWalletNameUseCase).build() + val scannedWallet = userWalletBuilderFactory.create(scanResponse = result.data).build() ensure(walletId == scannedWallet?.walletId) { ScanCardToUnlockWalletError.WrongCardIsScanned diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt index 6fe44142e1..eecab67075 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt @@ -40,7 +40,7 @@ internal enum class Wallet2CobrandImage( BitcoinGold( cards2ResId = R.drawable.ill_bitcoingold_card2_120_106, cards3ResId = R.drawable.ill_bitcoingold_card3_120_106, - batchIds = setOf("AF71"), + batchIds = setOf("AF71", "AF990016", "AF990009"), ), BitcoinPizzaDay( @@ -169,6 +169,12 @@ internal enum class Wallet2CobrandImage( batchIds = setOf("AF95"), ), + Keiro( + cards2ResId = R.drawable.ill_keiro_card2_120_106, + cards3ResId = R.drawable.ill_keiro_card3_120_106, + batchIds = setOf("BB000017"), + ), + KishuInu( cards2ResId = R.drawable.ill_kishu_inu_card2_120_106, cards3ResId = R.drawable.ill_kishu_inu_card3_120_106, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt index 402b03dec0..238eecd063 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt @@ -3,10 +3,12 @@ package com.tangem.feature.wallet.presentation.wallet.loaders.implementors import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.deeplink.DeepLinksRegistry import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.nft.GetNFTCollectionsUseCase import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.tokens.ApplyTokenListSortingUseCase import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender @@ -21,7 +23,7 @@ import com.tangem.feature.wallet.presentation.wallet.subscribers.MultiWalletActi import com.tangem.feature.wallet.presentation.wallet.subscribers.MultiWalletTokenListSubscriber import com.tangem.feature.wallet.presentation.wallet.subscribers.MultiWalletWarningsSubscriber import com.tangem.feature.wallet.presentation.wallet.subscribers.WalletSubscriber -import com.tangem.features.swap.SwapFeatureToggles +import com.tangem.features.nft.NFTFeatureToggles @Suppress("LongParameterList") @ModelScoped @@ -34,14 +36,16 @@ internal class MultiWalletContentLoader( private val walletWarningsSingleEventSender: WalletWarningsSingleEventSender, private val walletWithFundsChecker: WalletWithFundsChecker, private val tokenListStore: MultiWalletTokenListStore, + private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val applyTokenListSortingUseCase: ApplyTokenListSortingUseCase, private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, private val runPolkadotAccountHealthCheckUseCase: RunPolkadotAccountHealthCheckUseCase, private val shouldSaveUserWalletsUseCase: ShouldSaveUserWalletsUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, - private val swapFeatureToggles: SwapFeatureToggles, private val deepLinksRegistry: DeepLinksRegistry, + private val nftFeatureToggles: NFTFeatureToggles, + private val walletsRepository: WalletsRepository, ) : WalletContentLoader(id = userWallet.walletId) { override fun create(): List { @@ -58,6 +62,15 @@ internal class MultiWalletContentLoader( runPolkadotAccountHealthCheckUseCase = runPolkadotAccountHealthCheckUseCase, deepLinksRegistry = deepLinksRegistry, ).let(::add) + if (nftFeatureToggles.isNFTEnabled) { + WalletNFTListSubscriber( + userWallet = userWallet, + getNFTCollectionsUseCase = getNFTCollectionsUseCase, + stateHolder = stateHolder, + walletsRepository = walletsRepository, + clickIntents = clickIntents, + ).let(::add) + } MultiWalletWarningsSubscriber( userWallet = userWallet, stateHolder = stateHolder, @@ -66,13 +79,11 @@ internal class MultiWalletContentLoader( walletWarningsAnalyticsSender = walletWarningsAnalyticsSender, walletWarningsSingleEventSender = walletWarningsSingleEventSender, ).let(::add) - if (swapFeatureToggles.isPromoStoriesEnabled) { - MultiWalletActionButtonsSubscriber( - userWallet = userWallet, - stateHolder = stateHolder, - getStoryContentUseCase = getStoryContentUseCase, - ).let(::add) - } + MultiWalletActionButtonsSubscriber( + userWallet = userWallet, + stateHolder = stateHolder, + getStoryContentUseCase = getStoryContentUseCase, + ).let(::add) WalletDropDownItemsSubscriber( stateHolder = stateHolder, shouldSaveUserWalletsUseCase = shouldSaveUserWalletsUseCase, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoaderFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoaderFactory.kt index 7bc706075d..0f4e9d37b4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoaderFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoaderFactory.kt @@ -3,10 +3,12 @@ package com.tangem.feature.wallet.presentation.wallet.loaders.implementors import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.deeplink.DeepLinksRegistry import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.nft.GetNFTCollectionsUseCase import com.tangem.domain.promo.GetStoryContentUseCase import com.tangem.domain.tokens.ApplyTokenListSortingUseCase import com.tangem.domain.tokens.RunPolkadotAccountHealthCheckUseCase import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender @@ -16,7 +18,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.GetMultiWalletWarnin import com.tangem.feature.wallet.presentation.wallet.domain.MultiWalletTokenListStore import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController -import com.tangem.features.swap.SwapFeatureToggles +import com.tangem.features.nft.NFTFeatureToggles import javax.inject.Inject @Suppress("LongParameterList") @@ -34,8 +36,10 @@ internal class MultiWalletContentLoaderFactory @Inject constructor( private val runPolkadotAccountHealthCheckUseCase: RunPolkadotAccountHealthCheckUseCase, private val shouldSaveUserWalletsUseCase: ShouldSaveUserWalletsUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, - private val swapFeatureToggles: SwapFeatureToggles, private val deepLinksRegistry: DeepLinksRegistry, + private val nftFeatureToggles: NFTFeatureToggles, + private val walletsRepository: WalletsRepository, + private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase, ) { fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): WalletContentLoader { @@ -54,8 +58,10 @@ internal class MultiWalletContentLoaderFactory @Inject constructor( runPolkadotAccountHealthCheckUseCase = runPolkadotAccountHealthCheckUseCase, getStoryContentUseCase = getStoryContentUseCase, shouldSaveUserWalletsUseCase = shouldSaveUserWalletsUseCase, - swapFeatureToggles = swapFeatureToggles, deepLinksRegistry = deepLinksRegistry, + nftFeatureToggles = nftFeatureToggles, + walletsRepository = walletsRepository, + getNFTCollectionsUseCase = getNFTCollectionsUseCase, ) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt index fd9277c21a..109e4deec2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt @@ -15,7 +15,6 @@ import com.tangem.feature.wallet.presentation.wallet.domain.MultiWalletTokenList import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.subscribers.* -import com.tangem.features.swap.SwapFeatureToggles @Suppress("LongParameterList") internal class SingleWalletWithTokenContentLoader( @@ -32,7 +31,6 @@ internal class SingleWalletWithTokenContentLoader( private val runPolkadotAccountHealthCheckUseCase: RunPolkadotAccountHealthCheckUseCase, private val shouldSaveUserWalletsUseCase: ShouldSaveUserWalletsUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, - private val swapFeatureToggles: SwapFeatureToggles, private val deepLinksRegistry: DeepLinksRegistry, ) : WalletContentLoader(id = userWallet.walletId) { @@ -57,13 +55,11 @@ internal class SingleWalletWithTokenContentLoader( walletWarningsAnalyticsSender = walletWarningsAnalyticsSender, walletWarningsSingleEventSender = walletWarningsSingleEventSender, ).let(::add) - if (swapFeatureToggles.isPromoStoriesEnabled) { - MultiWalletActionButtonsSubscriber( - userWallet = userWallet, - stateHolder = stateHolder, - getStoryContentUseCase = getStoryContentUseCase, - ).let(::add) - } + MultiWalletActionButtonsSubscriber( + userWallet = userWallet, + stateHolder = stateHolder, + getStoryContentUseCase = getStoryContentUseCase, + ).let(::add) WalletDropDownItemsSubscriber( stateHolder = stateHolder, shouldSaveUserWalletsUseCase = shouldSaveUserWalletsUseCase, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt index 1a7e15d40d..829bb4b3d6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt @@ -15,7 +15,6 @@ import com.tangem.feature.wallet.presentation.wallet.domain.MultiWalletTokenList import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents -import com.tangem.features.swap.SwapFeatureToggles import javax.inject.Inject // TODO: Refactor @@ -33,7 +32,6 @@ internal class SingleWalletWithTokenContentLoaderFactory @Inject constructor( private val runPolkadotAccountHealthCheckUseCase: RunPolkadotAccountHealthCheckUseCase, private val shouldSaveUserWalletsUseCase: ShouldSaveUserWalletsUseCase, private val getStoryContentUseCase: GetStoryContentUseCase, - private val swapFeatureToggles: SwapFeatureToggles, private val deepLinksRegistry: DeepLinksRegistry, ) { @@ -52,7 +50,6 @@ internal class SingleWalletWithTokenContentLoaderFactory @Inject constructor( runPolkadotAccountHealthCheckUseCase = runPolkadotAccountHealthCheckUseCase, getStoryContentUseCase = getStoryContentUseCase, shouldSaveUserWalletsUseCase = shouldSaveUserWalletsUseCase, - swapFeatureToggles = swapFeatureToggles, deepLinksRegistry = deepLinksRegistry, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/DepositButtonState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/DepositButtonState.kt deleted file mode 100644 index 9a490bf52e..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/DepositButtonState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.model - -internal data class DepositButtonState( - val isEnabled: Boolean, - val onClick: () -> Unit, -) \ 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 6389eb3a0f..bf64d76838 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 @@ -13,7 +13,6 @@ internal data class VisaTxDetailsBottomSheetConfig( val type: String, val status: String, val blockchainAmount: String, - val blockchainFee: String, val transactionAmount: String, val transactionCurrencyCode: String, val merchantName: String, @@ -27,7 +26,6 @@ internal data class VisaTxDetailsBottomSheetConfig( val type: String, val status: String, val blockchainAmount: String, - val blockchainFee: String, val transactionAmount: String, val currencyCode: String, val errorCode: Int, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNFTItemUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNFTItemUM.kt index 1d2dd2b8ba..f4406d5dc5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNFTItemUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNFTItemUM.kt @@ -10,7 +10,9 @@ sealed class WalletNFTItemUM { data object Loading : WalletNFTItemUM() - data object Empty : WalletNFTItemUM() + data class Empty( + val onItemClick: () -> Unit, + ) : WalletNFTItemUM() data object Failed : WalletNFTItemUM() @@ -19,6 +21,7 @@ sealed class WalletNFTItemUM { val collectionsCount: Int, val assetsCount: Int, val isFlickering: Boolean, + val onItemClick: () -> Unit, ) : WalletNFTItemUM() { @Immutable diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt index 42b4839fc4..cd4ed81df9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt @@ -22,6 +22,7 @@ internal sealed interface WalletState : WalletStateHolder { sealed class MultiCurrency : WalletState { abstract val tokensListState: WalletTokensListState + abstract val nftState: WalletNFTItemUM data class Content( override val pullToRefreshConfig: PullToRefreshConfig, @@ -30,6 +31,7 @@ internal sealed interface WalletState : WalletStateHolder { override val warnings: ImmutableList, override val bottomSheetConfig: TangemBottomSheetConfig?, override val tokensListState: WalletTokensListState, + override val nftState: WalletNFTItemUM, ) : MultiCurrency() data class Locked( @@ -46,6 +48,7 @@ internal sealed interface WalletState : WalletStateHolder { ) { override val tokensListState = WalletTokensListState.ContentState.Locked + override val nftState: WalletNFTItemUM = WalletNFTItemUM.Hidden } } @@ -96,7 +99,6 @@ internal sealed interface WalletState : WalletStateHolder { override val bottomSheetConfig: TangemBottomSheetConfig?, override val balancesAndLimitBlockState: BalancesAndLimitsBlockState, override val txHistoryState: TxHistoryState, - val depositButtonState: DepositButtonState, ) : Visa() data class Locked( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt index 72d2e0f783..8a258581d3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt @@ -5,21 +5,18 @@ import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletLoadingStateFactory import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import kotlinx.collections.immutable.toImmutableList internal class AddWalletTransformer( private val userWallet: UserWallet, private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, - private val walletFeatureToggles: WalletFeatureToggles, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { WalletLoadingStateFactory( clickIntents = clickIntents, walletImageResolver = walletImageResolver, - walletFeatureToggles = walletFeatureToggles, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt index ac0c59bc42..ee9f47ff70 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt @@ -8,7 +8,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.* import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletLoadingStateFactory import com.tangem.feature.wallet.presentation.wallet.state.utils.createStateByWalletType import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -18,14 +17,12 @@ internal class InitializeWalletsTransformer( private val wallets: List, private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, - private val walletFeatureToggles: WalletFeatureToggles, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { WalletLoadingStateFactory( clickIntents = clickIntents, walletImageResolver = walletImageResolver, - walletFeatureToggles = walletFeatureToggles, ) } @@ -38,7 +35,9 @@ internal class InitializeWalletsTransformer( if (userWallet.isLocked) { createLockedState(userWallet) } else { - walletLoadingStateFactory.create(userWallet) + walletLoadingStateFactory.create( + userWallet = userWallet, + ) } } .toImmutableList(), @@ -96,7 +95,7 @@ internal class InitializeWalletsTransformer( private fun createMultiWalletEnabledButtons(userWallet: UserWallet): PersistentList { val isSingleWalletWithToken = userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() - if (!walletFeatureToggles.isMainActionButtonsEnabled || isSingleWalletWithToken) return persistentListOf() + if (isSingleWalletWithToken) return persistentListOf() return persistentListOf( WalletManageButton.Buy(enabled = false, dimContent = false, onClick = {}), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt index ceecfc9cfc..b1b567d556 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt @@ -2,11 +2,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletLoadingStateFactory -import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import kotlinx.collections.immutable.toImmutableList /** @@ -23,14 +22,12 @@ internal class ReinitializeWalletTransformer( private val newUserWallet: UserWallet, private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, - private val walletFeatureToggles: WalletFeatureToggles, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { WalletLoadingStateFactory( clickIntents = clickIntents, walletImageResolver = walletImageResolver, - walletFeatureToggles = walletFeatureToggles, ) } @@ -38,7 +35,11 @@ internal class ReinitializeWalletTransformer( return prevState.copy( wallets = prevState.wallets .filterNot { it.walletCardState.id == prevWalletId } - .plus(element = walletLoadingStateFactory.create(newUserWallet)) + .plus( + element = walletLoadingStateFactory.create( + userWallet = newUserWallet, + ), + ) .toImmutableList(), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/RemoveNFTCollectionsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/RemoveNFTCollectionsTransformer.kt new file mode 100644 index 0000000000..b66ed6ba9a --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/RemoveNFTCollectionsTransformer.kt @@ -0,0 +1,23 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNFTItemUM +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState + +internal class RemoveNFTCollectionsTransformer( + userWalletId: UserWalletId, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState = when (prevState) { + is WalletState.MultiCurrency.Content -> prevState.copy( + nftState = WalletNFTItemUM.Hidden, + ) + is WalletState.SingleCurrency.Content, + is WalletState.Visa.Content, + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + is WalletState.Visa.Locked, + is WalletState.Visa.AccessTokenLocked, + -> prevState + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt new file mode 100644 index 0000000000..9c5fcb1479 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetNFTCollectionsTransformer.kt @@ -0,0 +1,76 @@ +package com.tangem.feature.wallet.presentation.wallet.state.transformers + +import com.tangem.domain.models.StatusSource +import com.tangem.domain.nft.models.* +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNFTItemUM +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState +import kotlinx.collections.immutable.toPersistentList + +internal class SetNFTCollectionsTransformer( + userWalletId: UserWalletId, + private val nftCollections: List, + private val onItemClick: () -> Unit, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState = when (prevState) { + is WalletState.MultiCurrency.Content -> prevState.copy( + nftState = when { + nftCollections.allCollectionsFailed() -> + WalletNFTItemUM.Failed + nftCollections.anyCollectionFailed() && nftCollections.allLoadedCollectionsEmpty() -> + WalletNFTItemUM.Failed + nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() -> + WalletNFTItemUM.Empty(onItemClick) + !nftCollections.allCollectionsLoaded() && nftCollections.allCollectionsEmpty() -> + WalletNFTItemUM.Loading + else -> createContentNFTItemUM(onItemClick) + }, + ) + is WalletState.SingleCurrency.Content, + is WalletState.Visa.Content, + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + is WalletState.Visa.Locked, + is WalletState.Visa.AccessTokenLocked, + -> prevState + } + + private fun createContentNFTItemUM(onItemClick: () -> Unit): WalletNFTItemUM.Content { + val collectionsContent = nftCollections + .map { it.content } + .filterIsInstance() + + val isFlickering = collectionsContent + .map { it.source } + .any { it == StatusSource.CACHE } + + val collections = collectionsContent + .mapNotNull { it.collections } + .flatten() + + return WalletNFTItemUM.Content( + previews = if (collections.size > NFT_COLLECTIONS_MAX_PREVIEWS_COUNT) { + collections + .take(NFT_COLLECTIONS_MAX_PREVIEWS_COUNT - 1) + .map { WalletNFTItemUM.Content.CollectionPreview.Image(it.logoUrl.orEmpty()) } + .plus(WalletNFTItemUM.Content.CollectionPreview.More) + .toPersistentList() + } else { + collections + .take(NFT_COLLECTIONS_MAX_PREVIEWS_COUNT) + .map { WalletNFTItemUM.Content.CollectionPreview.Image(it.logoUrl.orEmpty()) } + .toPersistentList() + }, + collectionsCount = collections.size, + assetsCount = collections + .sumOf { it.count }, + isFlickering = isFlickering, + onItemClick = onItemClick, + ) + } + + companion object { + private const val NFT_COLLECTIONS_MAX_PREVIEWS_COUNT = 4 + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt index 1465ccc33a..4ba7560ab0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetRefreshStateTransformer.kt @@ -3,7 +3,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.presentation.wallet.state.model.BalancesAndLimitsBlockState -import com.tangem.feature.wallet.presentation.wallet.state.model.DepositButtonState import com.tangem.feature.wallet.presentation.wallet.state.model.* import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate @@ -29,8 +28,8 @@ internal class SetRefreshStateTransformer( } is WalletState.Visa.Content -> { prevState.copy( + buttons = prevState.buttons.toUpdatedState(), pullToRefreshConfig = prevState.pullToRefreshConfig.toUpdatedState(isRefreshing), - depositButtonState = prevState.depositButtonState.toUpdatedState(isRefreshing), balancesAndLimitBlockState = prevState.balancesAndLimitBlockState.toUpdatedState(isRefreshing), ) } @@ -75,10 +74,6 @@ internal class SetRefreshStateTransformer( } } - private fun DepositButtonState.toUpdatedState(isRefreshing: Boolean): DepositButtonState { - return copy(isEnabled = !isRefreshing) - } - private fun BalancesAndLimitsBlockState.toUpdatedState(isRefreshing: Boolean): BalancesAndLimitsBlockState { return when (this) { is BalancesAndLimitsBlockState.Content -> copy(isEnabled = !isRefreshing) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetBalancesAndLimitsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt similarity index 68% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetBalancesAndLimitsTransformer.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt index 2655da4ba9..1d65f129ae 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetBalancesAndLimitsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetVisaInfoTransformer.kt @@ -7,6 +7,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.common.util.getCardsCount +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.visa.exception.RefreshTokenExpiredException import com.tangem.domain.visa.model.VisaCurrency import com.tangem.domain.wallets.models.UserWallet @@ -15,11 +16,14 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditiona import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletManageButton import com.tangem.utils.extensions.isZero +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf import org.joda.time.DateTime import org.joda.time.Days -internal class SetBalancesAndLimitsTransformer( +internal class SetVisaInfoTransformer( private val userWallet: UserWallet, private val maybeVisaCurrency: Either, private val clickIntents: WalletClickIntents, @@ -35,15 +39,15 @@ internal class SetBalancesAndLimitsTransformer( } return prevState.copy( + buttons = createVisaButtonsDimmed(), walletCardState = getErrorWalletCardState(prevState.walletCardState), - depositButtonState = prevState.depositButtonState.copy(isEnabled = false), balancesAndLimitBlockState = BalancesAndLimitsBlockState.Error, ) } return prevState.copy( + buttons = createVisaButtons(visaCurrency = visaCurrency), walletCardState = getContentWalletCardState(prevState.walletCardState, visaCurrency), - depositButtonState = prevState.depositButtonState.copy(isEnabled = true), balancesAndLimitBlockState = getContentBlockState(visaCurrency), ) } @@ -115,4 +119,43 @@ internal class SetBalancesAndLimitsTransformer( onUnlockVisaAccessNotificationClick = clickIntents::onUnlockVisaAccessClick, ) } + + private fun createVisaButtonsDimmed(): PersistentList { + return persistentListOf( + WalletManageButton.Receive(enabled = true, dimContent = true, onClick = {}, onLongClick = null), + WalletManageButton.Buy(enabled = true, dimContent = true, onClick = {}), + ) + } + + private fun createVisaButtons(visaCurrency: VisaCurrency): PersistentList { + // [Second Visa Iteration] Make VisaCurrency contain CryptoCurrencyStatus + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = visaCurrency.cryptoCurrency, + value = CryptoCurrencyStatus.Loaded( + amount = visaCurrency.balances.available, + fiatAmount = visaCurrency.balances.available.multiply(visaCurrency.fiatRate), + fiatRate = visaCurrency.fiatRate, + priceChange = visaCurrency.priceChange, + yieldBalance = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = visaCurrency.paymentAccountAddress, + sources = CryptoCurrencyStatus.Sources(), + ), + ) + + return return persistentListOf( + WalletManageButton.Receive( + enabled = true, + dimContent = false, + onClick = { clickIntents.onReceiveClick(cryptoCurrencyStatus = cryptoCurrencyStatus) }, + onLongClick = { clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus = cryptoCurrencyStatus) }, + ), + WalletManageButton.Buy( + enabled = true, + dimContent = false, + onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId) }, + ), + ) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt index 61cb2c07b4..d741af8e9a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt @@ -7,7 +7,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenSta import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletLoadingStateFactory import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import kotlinx.collections.immutable.toImmutableList import timber.log.Timber @@ -15,14 +14,12 @@ internal class UnlockWalletTransformer( private val unlockedWallets: List, private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, - private val walletFeatureToggles: WalletFeatureToggles, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { WalletLoadingStateFactory( clickIntents = clickIntents, walletImageResolver = walletImageResolver, - walletFeatureToggles = walletFeatureToggles, ) } @@ -46,7 +43,9 @@ internal class UnlockWalletTransformer( is WalletState.MultiCurrency.Locked, is WalletState.SingleCurrency.Locked, is WalletState.Visa.Locked, - -> walletLoadingStateFactory.create(userWallet = unlockedWallet) + -> walletLoadingStateFactory.create( + userWallet = unlockedWallet, + ) is WalletState.MultiCurrency.Content, is WalletState.SingleCurrency.Content, is WalletState.Visa.Content, 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 dee6043f7c..ba80bbf6a3 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 @@ -33,7 +33,6 @@ internal class VisaTxDetailsBottomSheetConverter( type = details.type.capitalize(), status = details.status.capitalize(), blockchainAmount = formatNetworkAmount(details.blockchainAmount), - blockchainFee = formatNetworkAmount(details.blockchainFee), transactionAmount = formatFiatAmount(details.transactionAmount, details.fiatCurrency), transactionCurrencyCode = details.transactionCurrencyCode.toString(), merchantName = details.merchantName?.capitalize() ?: UNKNOWN, @@ -52,7 +51,6 @@ internal class VisaTxDetailsBottomSheetConverter( type = request.requestType.capitalize(), status = request.requestStatus.capitalize(), blockchainAmount = formatNetworkAmount(request.blockchainAmount), - blockchainFee = formatNetworkAmount(request.blockchainFee), transactionAmount = formatFiatAmount(request.transactionAmount, request.fiatCurrency), currencyCode = request.billingCurrencyCode.toString(), errorCode = request.errorCode, 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 6223bc1d05..439993d834 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,15 +1,14 @@ package com.tangem.feature.wallet.presentation.wallet.state.utils +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.transactions.state.TxHistoryState -import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver import com.tangem.feature.wallet.presentation.wallet.state.model.* -import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow internal class WalletLoadingStateFactory( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, - private val walletFeatureToggles: WalletFeatureToggles, ) { fun create(userWallet: UserWallet): WalletState { @@ -41,6 +39,7 @@ internal class WalletLoadingStateFactory( warnings = persistentListOf(), bottomSheetConfig = null, tokensListState = WalletTokensListState.ContentState.Loading, + nftState = WalletNFTItemUM.Hidden, ) } @@ -67,7 +66,7 @@ internal class WalletLoadingStateFactory( return WalletState.Visa.Content( pullToRefreshConfig = createPullToRefreshConfig(), walletCardState = userWallet.toLoadingWalletCardState(), - buttons = createMultiWalletActions(userWallet), + buttons = createVisaDimmedButtons(), warnings = persistentListOf(), bottomSheetConfig = null, balancesAndLimitBlockState = BalancesAndLimitsBlockState.Loading, @@ -76,12 +75,14 @@ internal class WalletLoadingStateFactory( value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick), ), ), - depositButtonState = DepositButtonState(isEnabled = false, clickIntents::onDepositClick), ) } private fun createPullToRefreshConfig(): PullToRefreshConfig { - return PullToRefreshConfig(onRefresh = { clickIntents.onRefreshSwipe(it.value) }, isRefreshing = false) + return PullToRefreshConfig( + onRefresh = { clickIntents.onRefreshSwipe(it.value) }, + isRefreshing = false, + ) } private fun UserWallet.toLoadingWalletCardState(): WalletCardState { @@ -96,7 +97,7 @@ internal class WalletLoadingStateFactory( private fun createMultiWalletActions(userWallet: UserWallet): PersistentList { val isSingleWalletWithToken = userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() - if (!walletFeatureToggles.isMainActionButtonsEnabled || isSingleWalletWithToken) return persistentListOf() + if (isSingleWalletWithToken) return persistentListOf() return persistentListOf( WalletManageButton.Buy( @@ -117,6 +118,13 @@ internal class WalletLoadingStateFactory( ) } + private fun createVisaDimmedButtons(): PersistentList { + return persistentListOf( + WalletManageButton.Receive(enabled = true, dimContent = true, onClick = {}, onLongClick = null), + WalletManageButton.Buy(enabled = true, dimContent = true, onClick = {}), + ) + } + private fun createDimmedButtons(): PersistentList { return persistentListOf( WalletManageButton.Receive(enabled = true, dimContent = true, onClick = {}, onLongClick = null), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/VisaWalletSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/VisaWalletSubscriber.kt index 239ee8c80d..03e49e67f0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/VisaWalletSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/VisaWalletSubscriber.kt @@ -13,7 +13,7 @@ import com.tangem.domain.visa.model.VisaTxHistoryItem import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController -import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetBalancesAndLimitsTransformer +import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetVisaInfoTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTxHistoryCountTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTxHistoryItemsErrorTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTxHistoryItemsTransformer @@ -62,7 +62,7 @@ internal class VisaWalletSubscriber( private fun setLoadedCurrencyState(maybeCurrency: Either) { stateController.update( - SetBalancesAndLimitsTransformer( + SetVisaInfoTransformer( userWallet = userWallet, maybeVisaCurrency = maybeCurrency, clickIntents = clickIntents, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt new file mode 100644 index 0000000000..67a36a2935 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletNFTListSubscriber.kt @@ -0,0 +1,52 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import com.tangem.domain.nft.GetNFTCollectionsUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents +import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.feature.wallet.presentation.wallet.state.transformers.RemoveNFTCollectionsTransformer +import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetNFTCollectionsTransformer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* + +internal class WalletNFTListSubscriber( + private val userWallet: UserWallet, + private val stateHolder: WalletStateController, + private val walletsRepository: WalletsRepository, + private val getNFTCollectionsUseCase: GetNFTCollectionsUseCase, + private val clickIntents: WalletClickIntents, +) : WalletSubscriber() { + + @OptIn(ExperimentalCoroutinesApi::class) + override fun create(coroutineScope: CoroutineScope): Flow<*> = walletsRepository + .nftEnabledStatus(userWallet.walletId) + .distinctUntilChanged() + .flatMapLatest { nftEnabled -> + // if NFT is enabled for this wallet, then start observing changes from store and apply transformer if need + if (nftEnabled) { + getNFTCollectionsUseCase(userWallet.walletId) + .shareIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(), + replay = 1, + ) + .onEach { + stateHolder.update( + SetNFTCollectionsTransformer( + userWalletId = userWallet.walletId, + nftCollections = it, + onItemClick = { clickIntents.onNFTClick(userWallet.walletId) }, + ), + ) + } + } else { + // otherwise, hide NFT from wallet + stateHolder.update( + RemoveNFTCollectionsTransformer(userWallet.walletId), + ) + emptyFlow() + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 69c2f73c77..01de3ee5da 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -47,10 +47,10 @@ import com.tangem.common.ui.expressStatus.expressTransactionsItems import com.tangem.core.ui.components.atoms.Hand import com.tangem.core.ui.components.atoms.handComposableComponentHeight import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheet -import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheet -import com.tangem.core.ui.components.bottomsheets.tokenreceive.TokenReceiveBottomSheetConfig +import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheet +import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheet +import com.tangem.common.ui.bottomsheet.receive.TokenReceiveBottomSheetConfig import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.rememberIsKeyboardVisible import com.tangem.core.ui.components.sheetscaffold.* @@ -59,6 +59,7 @@ import com.tangem.core.ui.components.snackbar.TangemSnackbar import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.event.StateEvent import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalIsNavigationRefactoringEnabled import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme @@ -80,12 +81,12 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.TokenActionsB import com.tangem.feature.wallet.presentation.wallet.ui.components.WalletsList import com.tangem.feature.wallet.presentation.wallet.ui.components.common.* import com.tangem.feature.wallet.presentation.wallet.ui.components.common.actions +import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.nftCollections import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.BalancesAndLimitsBottomSheet import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.VisaTxDetailsBottomSheet import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.balancesAndLimitsBlock -import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.depositButton import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator import com.tangem.features.markets.entry.BottomSheetState import com.tangem.features.markets.entry.MarketsEntryComponent @@ -230,11 +231,6 @@ private fun WalletContent( } (selectedWallet as? WalletState.Visa.Content)?.let { - depositButton( - modifier = itemModifier.fillMaxWidth(), - state = it.depositButtonState, - ) - balancesAndLimitsBlock( modifier = itemModifier, state = it.balancesAndLimitBlockState, @@ -248,6 +244,8 @@ private fun WalletContent( modifier = movableItemModifier, ) + nftCollections(state = selectedWallet, itemModifier = itemModifier) + organizeTokens(state = selectedWallet, itemModifier = itemModifier) } @@ -430,7 +428,11 @@ private inline fun BaseScaffoldWithMarkets( .padding(bottom = 24.dp) .fillMaxWidth(fraction = 0.7f), isVisible = state.showMarketsOnboarding, - availableHeight = maxHeight - statusBarHeight - bottomBarHeight, + availableHeight = if (LocalIsNavigationRefactoringEnabled.current) { + maxHeight + } else { + maxHeight - statusBarHeight - bottomBarHeight + }, bottomSheetState = bottomSheetState, ) } @@ -706,6 +708,15 @@ internal fun LazyListScope.organizeTokens(state: WalletState, itemModifier: Modi } } +internal fun LazyListScope.nftCollections(state: WalletState, itemModifier: Modifier) { + (state as? WalletState.MultiCurrency)?.let { + nftCollections( + modifier = itemModifier, + state = it.nftState, + ) + } +} + @Composable private fun ShowBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) { if (bottomSheetConfig != null) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/PushNotificationsBottomSheet.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/PushNotificationsBottomSheet.kt index 4bbb305824..67fec39547 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/PushNotificationsBottomSheet.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/PushNotificationsBottomSheet.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.showcase.ShowcaseContent import com.tangem.core.ui.components.showcase.model.ShowcaseItemModel diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TokenActionsBottomSheet.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TokenActionsBottomSheet.kt index 5e4db0ab08..ea93bb097c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TokenActionsBottomSheet.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TokenActionsBottomSheet.kt @@ -9,7 +9,7 @@ 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.SimpleSettingsRow -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.getDefaultRowColors import com.tangem.core.ui.components.getWarningRowColors diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletNFTItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletNFTItem.kt index d31a09e214..079f2876e1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletNFTItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletNFTItem.kt @@ -34,19 +34,19 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Composable -internal fun WalletNFTItem(state: WalletNFTItemUM, modifier: Modifier = Modifier, onClick: () -> Unit = { }) { +internal fun WalletNFTItem(state: WalletNFTItemUM, modifier: Modifier = Modifier) { when (state) { is WalletNFTItemUM.Hidden -> Unit is WalletNFTItemUM.Empty -> WalletNFTItemEmpty( modifier = modifier, - onClick = onClick, + onClick = state.onItemClick, ) is WalletNFTItemUM.Failed -> WalletNFTItemFailed(modifier = modifier) is WalletNFTItemUM.Loading -> WalletNFTItemLoading(modifier = modifier) is WalletNFTItemUM.Content -> WalletNFTItemContent( state = state, - onClick = onClick, + onClick = state.onItemClick, modifier = modifier, ) } @@ -131,7 +131,20 @@ private fun WalletNFTItemFailed(modifier: Modifier = Modifier) { RowContentContainer( modifier = modifier, icon = { - CollectionsPreviewsPlaceholder() + Box( + modifier = Modifier + .size(TangemTheme.dimens.size36) + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .background(TangemTheme.colors.field.primary), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens.size20), + painter = painterResource(R.drawable.ic_error_sync_24), + tint = TangemTheme.colors.icon.informative, + contentDescription = null, + ) + } }, text = { Text( @@ -158,7 +171,12 @@ private fun WalletNFTItemLoading(modifier: Modifier = Modifier) { RowContentContainer( modifier = modifier, icon = { - CollectionsPreviewsPlaceholder() + Box( + modifier = Modifier + .size(TangemTheme.dimens.size36) + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) + .background(TangemTheme.colors.field.primary), + ) }, text = { TextShimmer( @@ -178,16 +196,6 @@ private fun WalletNFTItemLoading(modifier: Modifier = Modifier) { ) } -@Composable -private fun CollectionsPreviewsPlaceholder(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .size(TangemTheme.dimens.size36) - .clip(RoundedCornerShape(TangemTheme.dimens.radius8)) - .background(TangemTheme.colors.field.primary), - ) -} - @Composable @Suppress("MagicNumber") private fun BoxScope.CollectionsPreviews(previews: ImmutableList, modifier: Modifier = Modifier) { @@ -207,7 +215,7 @@ private fun BoxScope.CollectionsPreviews(previews: ImmutableList { SubcomposeAsyncImage( modifier = modifier, - model = s, + model = s.url, loading = { RectangleShimmer(radius = 0.dp) }, @@ -386,18 +394,17 @@ private fun RowContentContainer( @Preview(widthDp = 360) @Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun Preview_WalletNFTItem_InLight(@PreviewParameter(WalletNFTItemProvider::class) state: WalletNFTItemUM) { +private fun Preview_WalletNFTItem(@PreviewParameter(WalletNFTItemProvider::class) state: WalletNFTItemUM) { TangemThemePreview { - WalletNFTItem( - state = state, - onClick = {}, - ) + WalletNFTItem(state = state) } } private class WalletNFTItemProvider : CollectionPreviewParameterProvider( collection = listOf( - WalletNFTItemUM.Empty, + WalletNFTItemUM.Empty( + onItemClick = { }, + ), WalletNFTItemUM.Loading, WalletNFTItemUM.Failed, WalletNFTItemUM.Content( @@ -407,6 +414,7 @@ private class WalletNFTItemProvider : CollectionPreviewParameterProvider +} \ No newline at end of file diff --git a/features/walletconnect/impl/.gitignore b/features/walletconnect/impl/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/walletconnect/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/walletconnect/impl/build.gradle.kts b/features/walletconnect/impl/build.gradle.kts new file mode 100644 index 0000000000..e85bbfc291 --- /dev/null +++ b/features/walletconnect/impl/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.walletconnect.impl" +} + +dependencies { + implementation(projects.features.walletconnect.api) + implementation(projects.domain.walletConnect) + implementation(projects.domain.walletConnect.models) + + /** Core */ + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.common.routing) + + /** Domain models */ + implementation(projects.domain.qrScanning.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets.models) + + /** Domain */ + implementation(projects.domain.qrScanning) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + /** AndroidX */ + implementation(deps.androidx.activity.compose) + + /** Compose */ + implementation(deps.compose.accompanist.systemUiController) + implementation(deps.compose.coil) + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + + /** Other */ + implementation(deps.arrow.core) + implementation(deps.decompose.ext.compose) + implementation(deps.kotlin.immutable.collections) + implementation(deps.timber) +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/ConnectionsComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/ConnectionsComponent.kt new file mode 100644 index 0000000000..09c4b03ff5 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/ConnectionsComponent.kt @@ -0,0 +1,24 @@ +package com.tangem.features.walletconnect.connections.components + +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.features.walletconnect.connections.model.WcConnectionsModel +import com.tangem.features.walletconnect.connections.ui.WcConnectionsContent + +internal class ConnectionsComponent( + appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: WcConnectionsModel = getOrCreateModel() + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + WcConnectionsContent(modifier = modifier, state = state) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/DefaultWalletConnectEntryComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/DefaultWalletConnectEntryComponent.kt new file mode 100644 index 0000000000..3f3ee812d0 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/DefaultWalletConnectEntryComponent.kt @@ -0,0 +1,58 @@ +package com.tangem.features.walletconnect.connections.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.walletconnect.components.WalletConnectEntryComponent +import com.tangem.features.walletconnect.connections.routes.ConnectionsInnerRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("UnusedPrivateMember") +internal class DefaultWalletConnectEntryComponent @AssistedInject constructor( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted params: Unit, +) : AppComponentContext by appComponentContext, WalletConnectEntryComponent { + + private val contentNavigation = StackNavigation() + private val contentStack = childStack( + source = contentNavigation, + serializer = ConnectionsInnerRoute.serializer(), + initialConfiguration = ConnectionsInnerRoute.Connections, + childFactory = ::screenChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val childStack by contentStack.subscribeAsState() + + BackHandler(onBack = router::pop) + Children(stack = childStack, animation = stackAnimation()) { child -> + child.instance.Content(modifier = modifier) + } + } + + private fun screenChild( + config: ConnectionsInnerRoute, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + ConnectionsInnerRoute.Connections -> ConnectionsComponent(childByContext(componentContext)) + ConnectionsInnerRoute.QrScan -> TODO("[REDACTED_JIRA]") + } + + @AssistedFactory + interface Factory : WalletConnectEntryComponent.Factory { + override fun create(context: AppComponentContext, params: Unit): DefaultWalletConnectEntryComponent + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcConnectionsState.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcConnectionsState.kt new file mode 100644 index 0000000000..62474e35b9 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/entity/WcConnectionsState.kt @@ -0,0 +1,32 @@ +package com.tangem.features.walletconnect.connections.entity + +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import kotlinx.collections.immutable.ImmutableList + +internal data class WcConnectionsState( + val topAppBarConfig: WcConnectionsTopAppBarConfig, + val connections: ImmutableList, + val onNewConnectionClick: () -> Unit, +) + +internal data class WcConnectionsTopAppBarConfig( + val startButtonUM: TopAppBarButtonUM, + val endActions: ImmutableList, +) + +internal data class WcConnectionsUM( + val userWalletId: String, + val walletName: String, + val connectedApps: ImmutableList, +) + +internal data class WcConnectedAppInfo( + val name: String, + val iconUrl: String, + val subtitle: TextReference = stringReference("Connected App"), + val isVerified: Boolean = false, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectionsModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectionsModel.kt new file mode 100644 index 0000000000..ada41bf7d8 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectionsModel.kt @@ -0,0 +1,72 @@ +package com.tangem.features.walletconnect.connections.model + +import arrow.core.getOrElse +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.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.features.walletconnect.connections.entity.WcConnectionsState +import com.tangem.features.walletconnect.connections.ui.preview.WcConnectionsPreviewData +import com.tangem.features.walletconnect.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import timber.log.Timber +import javax.inject.Inject + +@ModelScoped +internal class WcConnectionsModel @Inject constructor( + private val router: Router, + private val uiMessageSender: UiMessageSender, + private val listenToQrScanningUseCase: ListenToQrScanningUseCase, + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + val uiState: StateFlow = MutableStateFlow( + WcConnectionsPreviewData.stateForModel( + onBackClicked = router::pop, + openQrScanScreen = ::openQrScanScreen, + disconnectAll = ::disconnectAll, + ), + ).asStateFlow() + + init { + listenQrUpdates() + } + + private fun listenQrUpdates() { + listenToQrScanningUseCase(SourceType.WALLET_CONNECT) + .getOrElse { emptyFlow() } + .onEach { + // TODO: [REDACTED_JIRA] + Timber.d(it) + } + .launchIn(modelScope) + } + + private fun openQrScanScreen() { + router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.WalletConnect)) + } + + private fun disconnectAll() { + val message = DialogMessage( + title = resourceReference(R.string.wc_disconnect_all_alert_title), + message = resourceReference(R.string.wc_disconnect_all_alert_desc), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_disconnect), + onClick = { + // TODO(wc): disconnect all + }, + ) + }, + secondActionBuilder = { cancelAction() }, + ) + uiMessageSender.send(message) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/ConnectionsInnerRoute.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/ConnectionsInnerRoute.kt new file mode 100644 index 0000000000..a07346591c --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routes/ConnectionsInnerRoute.kt @@ -0,0 +1,12 @@ +package com.tangem.features.walletconnect.connections.routes + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class ConnectionsInnerRoute { + @Serializable + data object Connections : ConnectionsInnerRoute() + + @Serializable + data object QrScan : ConnectionsInnerRoute() +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt new file mode 100644 index 0000000000..005ff5c7a4 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/WcConnectionsContent.kt @@ -0,0 +1,299 @@ +package com.tangem.features.walletconnect.connections.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +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 androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import coil.compose.AsyncImage +import com.tangem.core.ui.components.BottomFade +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu +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.walletconnect.connections.entity.WcConnectedAppInfo +import com.tangem.features.walletconnect.connections.entity.WcConnectionsState +import com.tangem.features.walletconnect.connections.entity.WcConnectionsTopAppBarConfig +import com.tangem.features.walletconnect.connections.entity.WcConnectionsUM +import com.tangem.features.walletconnect.connections.ui.preview.WcConnectionsPreviewData +import com.tangem.features.walletconnect.impl.R +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun WcConnectionsContent(state: WcConnectionsState, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + containerColor = TangemTheme.colors.background.secondary, + topBar = { + ConnectionsTopBar( + modifier = Modifier.statusBarsPadding(), + config = state.topAppBarConfig, + showEndButton = state.connections.isNotEmpty(), + ) + }, + floatingActionButton = { + if (state.connections.isNotEmpty()) { + NewConnectionButton( + onClick = state.onNewConnectionClick, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + } + }, + floatingActionButtonPosition = FabPosition.Center, + content = { innerPadding -> + Content( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + connections = state.connections, + onNewConnectionClick = state.onNewConnectionClick, + ) + }, + ) +} + +@Composable +private fun Content( + connections: ImmutableList, + onNewConnectionClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (connections.isEmpty()) { + EmptyConnectionsBlock(modifier = modifier, onNewConnectionClick = onNewConnectionClick) + } else { + ConnectionsBlock(modifier = modifier, connections = connections) + } +} + +@Composable +private fun EmptyConnectionsBlock(onNewConnectionClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.img_wallet_connect_76), + contentDescription = "Wallet Connect", + modifier = Modifier.size(76.dp), + ) + Text( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing24), + text = stringResourceSafe(R.string.wc_no_sessions_title), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.h3, + ) + Text( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + text = stringResourceSafe(R.string.wc_no_sessions_desc), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body1, + ) + NewConnectionButton( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing48), + onClick = onNewConnectionClick, + ) + } +} + +@Composable +private fun ConnectionsBlock(connections: ImmutableList, modifier: Modifier = Modifier) { + Box(modifier = modifier) { + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing76 + bottomBarHeight, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + items( + items = connections, + key = { it.userWalletId }, + itemContent = { + ConnectionItem( + modifier = Modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.primary, + shape = RoundedCornerShape(TangemTheme.dimens.radius14), + ) + .clip(RoundedCornerShape(TangemTheme.dimens.radius14)), + connection = it, + ) + }, + ) + } + BottomFade(modifier = Modifier.align(Alignment.BottomCenter)) + } +} + +@Composable +private fun ConnectionItem(connection: WcConnectionsUM, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + key("${connection.userWalletId}_${connection.walletName}") { + Text( + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp, start = 12.dp, end = 12.dp), + text = connection.walletName, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.subtitle2, + ) + } + connection.connectedApps.fastForEach { appInfo -> + key("${connection.userWalletId}_${connection.walletName}_${appInfo.name}_${appInfo.iconUrl}") { + AppInfoItem( + appInfo = appInfo, + modifier = Modifier + .clickable(onClick = appInfo.onClick) + .padding(all = 12.dp), + ) + } + } + } +} + +@Composable +private fun AppInfoItem(appInfo: WcConnectedAppInfo, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + AsyncImage( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(TangemTheme.dimens.radius8)), + model = appInfo.iconUrl, + contentDescription = appInfo.name, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), + ) { + Text( + text = appInfo.name, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle2, + ) + Text( + text = appInfo.subtitle.resolveReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConnectionsTopBar( + config: WcConnectionsTopAppBarConfig, + showEndButton: Boolean, + modifier: Modifier = Modifier, +) { + var showDropdownMenu by rememberSaveable { mutableStateOf(false) } + // TangemTopAppBar is not friendly with dropdown menu + TopAppBar( + modifier = modifier, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = TangemTheme.colors.background.secondary, + titleContentColor = TangemTheme.colors.icon.primary1, + actionIconContentColor = TangemTheme.colors.icon.primary1, + ), + navigationIcon = { + IconButton(onClick = config.startButtonUM.onIconClicked) { + Icon( + painter = painterResource(id = config.startButtonUM.iconRes), + tint = TangemTheme.colors.icon.primary1, + contentDescription = "Back", + ) + } + }, + title = { + Text( + text = stringResourceSafe(R.string.wc_connections), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + actions = { + AnimatedVisibility(showEndButton) { + IconButton(onClick = { showDropdownMenu = true }) { + Icon( + painter = painterResource(id = R.drawable.ic_more_vertical_24), + tint = TangemTheme.colors.icon.primary1, + contentDescription = "More", + ) + } + } + + TangemDropdownMenu( + expanded = showDropdownMenu, + modifier = Modifier.background(TangemTheme.colors.background.primary), + onDismissRequest = { showDropdownMenu = false }, + content = { + config.endActions.fastForEach { + TangemDropdownItem( + item = it, + dismissParent = { showDropdownMenu = false }, + ) + } + }, + ) + }, + ) +} + +@Composable +private fun NewConnectionButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + PrimaryButton( + modifier = modifier, + text = stringResourceSafe(R.string.wc_new_connection), + onClick = onClick, + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun WcConnectionsContentPreview( + @PreviewParameter(WcConnectionsStateProvider::class) params: WcConnectionsState, +) { + TangemThemePreview { + WcConnectionsContent(state = params) + } +} + +private class WcConnectionsStateProvider : CollectionPreviewParameterProvider( + listOf( + WcConnectionsPreviewData.stateWithEmptyConnections, + WcConnectionsPreviewData.fullState, + ), +) \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/preview/WcConnectionsPreviewData.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/preview/WcConnectionsPreviewData.kt new file mode 100644 index 0000000000..57ce635a39 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/ui/preview/WcConnectionsPreviewData.kt @@ -0,0 +1,141 @@ +package com.tangem.features.walletconnect.connections.ui.preview + +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.walletconnect.connections.entity.WcConnectedAppInfo +import com.tangem.features.walletconnect.connections.entity.WcConnectionsState +import com.tangem.features.walletconnect.connections.entity.WcConnectionsTopAppBarConfig +import com.tangem.features.walletconnect.connections.entity.WcConnectionsUM +import com.tangem.features.walletconnect.impl.R +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber + +internal object WcConnectionsPreviewData { + private const val BASE_URL = "https://raw.githubusercontent.com/TaranVH/LOGOS/refs/heads/master/Crypto" + private val connections = persistentListOf( + WcConnectionsUM( + userWalletId = "wallet_id_1", + walletName = "Wallet 2.0", + connectedApps = persistentListOf( + WcConnectedAppInfo( + name = "Moralis", + iconUrl = "$BASE_URL/satoshi.png", + isVerified = true, + onClick = { Timber.d("Moralis clicked") }, + ), + WcConnectedAppInfo( + name = "website.com", + iconUrl = "$BASE_URL/doge_again_lol.jpg", + onClick = { Timber.d("website.com clicked") }, + ), + ), + ), + WcConnectionsUM( + userWalletId = "wallet_id_2", + walletName = "Bitcoin", + connectedApps = persistentListOf( + WcConnectedAppInfo( + name = "React app", + iconUrl = "$BASE_URL/LiteCoin.png", + isVerified = true, + onClick = { Timber.d("React app clicked") }, + ), + ), + ), + WcConnectionsUM( + userWalletId = "wallet_id_3", + walletName = "Wallet 2.0", + connectedApps = persistentListOf( + WcConnectedAppInfo( + name = "Moralis", + iconUrl = "$BASE_URL/satoshi.png", + isVerified = true, + onClick = { Timber.d("Moralis clicked") }, + ), + WcConnectedAppInfo( + name = "website.com", + iconUrl = "$BASE_URL/doge_again_lol.jpg", + onClick = { Timber.d("website.com clicked") }, + ), + ), + ), + WcConnectionsUM( + userWalletId = "wallet_id_4", + walletName = "Bitcoin", + connectedApps = persistentListOf( + WcConnectedAppInfo( + name = "React app", + iconUrl = "$BASE_URL/LiteCoin.png", + isVerified = true, + onClick = { Timber.d("React app clicked") }, + ), + ), + ), + WcConnectionsUM( + userWalletId = "wallet_id_5", + walletName = "Wallet 2.0", + connectedApps = persistentListOf( + WcConnectedAppInfo( + name = "Moralis", + iconUrl = "$BASE_URL/satoshi.png", + isVerified = true, + onClick = { Timber.d("Moralis clicked") }, + ), + WcConnectedAppInfo( + name = "website.com", + iconUrl = "$BASE_URL/doge_again_lol.jpg", + onClick = { Timber.d("website.com clicked") }, + ), + ), + ), + WcConnectionsUM( + userWalletId = "wallet_id_6", + walletName = "Bitcoin", + connectedApps = persistentListOf( + WcConnectedAppInfo( + name = "React app", + iconUrl = "$BASE_URL/LiteCoin.png", + isVerified = true, + onClick = { Timber.d("React app clicked") }, + ), + ), + ), + ) + val stateWithEmptyConnections = WcConnectionsState( + topAppBarConfig = WcConnectionsTopAppBarConfig( + startButtonUM = TopAppBarButtonUM( + iconRes = R.drawable.ic_back_24, + onIconClicked = {}, + enabled = true, + ), + endActions = persistentListOf( + TangemDropdownMenuItem( + title = resourceReference(R.string.wc_disconnect_all), + textColorProvider = { TangemTheme.colors.text.warning }, + onClick = { }, + ), + ), + ), + connections = persistentListOf(), + onNewConnectionClick = {}, + ) + val fullState = stateWithEmptyConnections.copy(connections = connections) + fun stateForModel( + onBackClicked: () -> Unit, + openQrScanScreen: () -> Unit, + disconnectAll: () -> Unit, + ): WcConnectionsState { + return fullState.copy( + topAppBarConfig = fullState.topAppBarConfig.copy( + startButtonUM = fullState.topAppBarConfig.startButtonUM.copy(onIconClicked = onBackClicked), + endActions = fullState.topAppBarConfig.endActions + .map { it.copy(onClick = disconnectAll) } + .toImmutableList(), + ), + onNewConnectionClick = openQrScanScreen, + ) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectFeatureModule.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectFeatureModule.kt new file mode 100644 index 0000000000..e8a98073d4 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectFeatureModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.walletconnect.di + +import com.tangem.features.walletconnect.components.WalletConnectEntryComponent +import com.tangem.features.walletconnect.connections.components.DefaultWalletConnectEntryComponent +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 WalletConnectFeatureModule { + + @Binds + @Singleton + fun bindWalletConnectEntryComponentFactory( + factory: DefaultWalletConnectEntryComponent.Factory, + ): WalletConnectEntryComponent.Factory +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt new file mode 100644 index 0000000000..9b8d6d4f4f --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.walletconnect.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.walletconnect.connections.model.WcConnectionsModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface WalletConnectModelModule { + + @Binds + @IntoMap + @ClassKey(WcConnectionsModel::class) + fun bindWcConnectionsModel(model: WcConnectionsModel): Model +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index b2f75cab44..8fbee2f5e1 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -47,7 +47,7 @@ coil = "2.1.0" compose-shimmer = "1.0.3" coroutine = "1.7.2" # 1.8+ is not compatible with tangem-sdk desugarJdkLibs = "1.1.5" -firebase = "33.7.0" +firebase = "33.10.0" googleMaterialComponent = "1.6.1" googlePlayReview = "2.0.1" googlePlayReviewKtx = "2.0.1" diff --git a/libs/blockchain-sdk/build.gradle.kts b/libs/blockchain-sdk/build.gradle.kts index 3a1001135f..5b0077a1d2 100644 --- a/libs/blockchain-sdk/build.gradle.kts +++ b/libs/blockchain-sdk/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.core.datasource) implementation(projects.core.configToggles) implementation(projects.core.utils) + implementation(projects.core.analytics) // endregion // region AndroidX libraries diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt index 1c9cc512e4..8a8f3e30d3 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/WalletManagerFactoryCreator.kt @@ -6,7 +6,6 @@ import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.common.datastorage.BlockchainDataStorage import com.tangem.blockchain.common.logging.BlockchainSDKLogger -import com.tangem.blockchainsdk.featuretoggles.BlockchainSDKFeatureToggles import com.tangem.blockchainsdk.providers.BlockchainProviderTypes import timber.log.Timber import javax.inject.Inject @@ -24,7 +23,6 @@ internal class WalletManagerFactoryCreator @Inject constructor( private val accountCreator: AccountCreator, private val blockchainDataStorage: BlockchainDataStorage, private val blockchainSDKLogger: BlockchainSDKLogger, - private val blockchainSDKFeatureToggles: BlockchainSDKFeatureToggles, ) { fun create(config: BlockchainSdkConfig, blockchainProviderTypes: BlockchainProviderTypes): WalletManagerFactory { @@ -34,9 +32,7 @@ internal class WalletManagerFactoryCreator @Inject constructor( config = config, blockchainProviderTypes = blockchainProviderTypes, accountCreator = accountCreator, - featureToggles = BlockchainFeatureToggles( - isEthereumEIP1559Enabled = blockchainSDKFeatureToggles.isEthereumEIP1559Enabled, - ), + featureToggles = BlockchainFeatureToggles(), blockchainDataStorage = blockchainDataStorage, loggers = listOf(blockchainSDKLogger), ) diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt index 0e46908b4f..cce9a8c942 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/BlockchainSDKFactoryModule.kt @@ -12,13 +12,11 @@ import com.tangem.blockchainsdk.DefaultBlockchainSDKFactory import com.tangem.blockchainsdk.WalletManagerFactoryCreator import com.tangem.blockchainsdk.accountcreator.DefaultAccountCreator import com.tangem.blockchainsdk.datastorage.DefaultBlockchainDataStorage -import com.tangem.blockchainsdk.featuretoggles.DefaultBlockchainSDKFeatureToggles import com.tangem.blockchainsdk.providers.BlockchainProviderTypesStore import com.tangem.blockchainsdk.providers.BlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.DevBlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.ProdBlockchainProvidersTypesManager import com.tangem.blockchainsdk.providers.dev.BlockchainProvidersResponseSerializer -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage @@ -92,13 +90,11 @@ internal object BlockchainSDKFactoryModule { tangemTechApi: TangemTechApi, appPreferencesStore: AppPreferencesStore, blockchainSDKLogger: BlockchainSDKLogger, - featureTogglesManager: FeatureTogglesManager, ): WalletManagerFactoryCreator { return WalletManagerFactoryCreator( accountCreator = DefaultAccountCreator(tangemTechApi), blockchainDataStorage = DefaultBlockchainDataStorage(appPreferencesStore), blockchainSDKLogger = blockchainSDKLogger, - blockchainSDKFeatureToggles = DefaultBlockchainSDKFeatureToggles(featureTogglesManager), ) } } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/ExcludedBlockchainsModule.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/ExcludedBlockchainsModule.kt index c63d1dd071..35b4d2ff9b 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/ExcludedBlockchainsModule.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/di/ExcludedBlockchainsModule.kt @@ -1,6 +1,8 @@ package com.tangem.blockchainsdk.di +import com.tangem.blockchainsdk.providers.BlockchainProvidersResponseMerger import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import dagger.Module import dagger.Provides @@ -17,4 +19,12 @@ internal object ExcludedBlockchainsModule { fun bindExcludedBlockchains(excludedBlockchainsManager: ExcludedBlockchainsManager): ExcludedBlockchains { return ExcludedBlockchains(excludedBlockchainsManager) } + + @Provides + @Singleton + fun bindBlockchainProvidersResponseMerger( + analyticsExceptionHandler: AnalyticsExceptionHandler, + ): BlockchainProvidersResponseMerger { + return BlockchainProvidersResponseMerger(analyticsExceptionHandler) + } } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/BlockchainSDKFeatureToggles.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/BlockchainSDKFeatureToggles.kt index 482df0cd5a..de92518ae5 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/BlockchainSDKFeatureToggles.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/BlockchainSDKFeatureToggles.kt @@ -1,6 +1,3 @@ package com.tangem.blockchainsdk.featuretoggles -internal interface BlockchainSDKFeatureToggles { - - val isEthereumEIP1559Enabled: Boolean -} \ No newline at end of file +internal interface BlockchainSDKFeatureToggles \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/DefaultBlockchainSDKFeatureToggles.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/DefaultBlockchainSDKFeatureToggles.kt index 2242f2675f..5be43a55e2 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/DefaultBlockchainSDKFeatureToggles.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/featuretoggles/DefaultBlockchainSDKFeatureToggles.kt @@ -1,11 +1,3 @@ package com.tangem.blockchainsdk.featuretoggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager - -internal class DefaultBlockchainSDKFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : BlockchainSDKFeatureToggles { - - override val isEthereumEIP1559Enabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(name = "IS_ETHEREUM_EIP_1559_ENABLED") -} \ No newline at end of file +internal class DefaultBlockchainSDKFeatureToggles : BlockchainSDKFeatureToggles \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseLoader.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseLoader.kt index 4336fc4db4..60c5024818 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseLoader.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseLoader.kt @@ -22,6 +22,7 @@ import javax.inject.Singleton internal class BlockchainProvidersResponseLoader @Inject constructor( private val tangemTechApi: TangemTechApi, private val blockchainProvidersStorage: BlockchainProvidersStorage, + private val blockchainProvidersResponseMerger: BlockchainProvidersResponseMerger, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -31,7 +32,7 @@ internal class BlockchainProvidersResponseLoader @Inject constructor( return loadRemote().fold( onSuccess = { remoteResponse -> - BlockchainProvidersResponseMerger.merge( + blockchainProvidersResponseMerger.merge( local = localResponse, remote = remoteResponse, ) diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseMerger.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseMerger.kt index ec57ec97ac..e64cc2f00e 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseMerger.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseMerger.kt @@ -1,19 +1,21 @@ package com.tangem.blockchainsdk.providers import androidx.core.util.PatternsCompat -import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.blockchainsdk.BlockchainProvidersResponse +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.datasource.local.config.providers.models.ProviderModel import timber.log.Timber +import javax.inject.Inject /** * Merger of [BlockchainProvidersResponse] * [REDACTED_AUTHOR] */ -internal object BlockchainProvidersResponseMerger { - - private val firebaseCrashlytics by lazy(FirebaseCrashlytics::getInstance) +internal class BlockchainProvidersResponseMerger @Inject internal constructor( + private val analyticsExceptionHandler: AnalyticsExceptionHandler, +) { private val forbiddenSchemes = listOf("wss://") @@ -41,7 +43,7 @@ internal object BlockchainProvidersResponseMerger { val missingBlockchains = result.keys - remote.keys val blockchainsWithoutProviders = remote.filterValues { it.isEmpty() }.keys - recordException(missingBlockchains = missingBlockchains + blockchainsWithoutProviders) + logException(missingBlockchains = missingBlockchains + blockchainsWithoutProviders) } return result.guaranteeUrlsEndWithSlash() @@ -74,15 +76,19 @@ internal object BlockchainProvidersResponseMerger { return PatternsCompat.WEB_URL.matcher(inputUrl).matches() } - private fun recordException(missingBlockchains: Set) { + private fun logException(missingBlockchains: Set) { val exception = IllegalStateException( - "Remote config does not contain required blockchains or providers information: " + - missingBlockchains.joinToString(), + "Remote config does not contain some blockchains or providers information", ) Timber.e(exception) - firebaseCrashlytics.recordException(exception) + analyticsExceptionHandler.sendException( + ExceptionAnalyticsEvent( + exception = exception, + params = mapOf("Missing blockchains" to missingBlockchains.joinToString()), + ), + ) } /* diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt index dc2f9b8a81..484b6f89ee 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/Blockchain.kt @@ -163,6 +163,8 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "scroll/test" -> Blockchain.ScrollTestnet "zklink" -> Blockchain.ZkLinkNova "zklink/test" -> Blockchain.ZkLinkNovaTestnet + "pepecoin" -> Blockchain.Pepecoin + "pepecoin/test" -> Blockchain.PepecoinTestnet else -> null } } @@ -323,6 +325,8 @@ fun Blockchain.toNetworkId(): String { Blockchain.ScrollTestnet -> "scroll/test" Blockchain.ZkLinkNova -> "zklink" Blockchain.ZkLinkNovaTestnet -> "zklink/test" + Blockchain.Pepecoin -> "pepecoin" + Blockchain.PepecoinTestnet -> "pepecoin/test" } } @@ -425,6 +429,7 @@ fun Blockchain.toCoinId(): String { Blockchain.ApeChain, Blockchain.ApeChainTestnet -> "apecoin" Blockchain.Scroll, Blockchain.ScrollTestnet -> "scroll" Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet -> "zklink" + Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> "pepecoin-network" } } @@ -463,10 +468,6 @@ fun Blockchain.amountToCreateAccount(walletManager: WalletManager, token: Token? } } -fun Blockchain.minimalAmount(): BigDecimal { - return BigDecimal.ONE.movePointLeft(decimals()) -} - const val OLD_POLYGON_NAME = "matic-network" const val NEW_POLYGON_NAME = "polygon-ecosystem-token" diff --git a/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseLoaderTest.kt b/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseLoaderTest.kt index 54d99add4a..64d82467cc 100644 --- a/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseLoaderTest.kt +++ b/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseLoaderTest.kt @@ -4,6 +4,8 @@ import com.google.common.truth.Truth import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tangem.blockchainsdk.providers.BlockchainProvidersResponseMergerTest.Companion.localResponse import com.tangem.blockchainsdk.providers.BlockchainProvidersResponseMergerTest.Companion.remoteResponse +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.config.providers.BlockchainProvidersStorage import com.tangem.datasource.local.config.providers.models.ProviderModel @@ -20,10 +22,16 @@ internal class BlockchainProvidersResponseLoaderTest { private val tangemTechApi = mockk() private val blockchainProvidersStorage = mockk() + private val analyticsExceptionHandler = object : AnalyticsExceptionHandler { + override fun sendException(event: ExceptionAnalyticsEvent) { + FirebaseCrashlytics.getInstance().recordException(event.exception) + } + } private val loader = BlockchainProvidersResponseLoader( tangemTechApi = tangemTechApi, blockchainProvidersStorage = blockchainProvidersStorage, + blockchainProvidersResponseMerger = BlockchainProvidersResponseMerger(analyticsExceptionHandler), dispatchers = TestingCoroutineDispatcherProvider(), ) diff --git a/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseMergerTest.kt b/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseMergerTest.kt index 53004c77b9..2fc0251d41 100644 --- a/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseMergerTest.kt +++ b/libs/blockchain-sdk/src/test/java/com/tangem/blockchainsdk/providers/BlockchainProvidersResponseMergerTest.kt @@ -2,7 +2,10 @@ package com.tangem.blockchainsdk.providers import com.google.common.truth.Truth import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.google.firebase.crashlytics.recordException import com.tangem.blockchainsdk.BlockchainProvidersResponse +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.datasource.local.config.providers.models.ProviderModel import io.mockk.* import org.junit.Before @@ -13,6 +16,14 @@ import org.junit.Test */ internal class BlockchainProvidersResponseMergerTest { + private val blockchainProvidersResponseMerger = BlockchainProvidersResponseMerger( + object : AnalyticsExceptionHandler { + override fun sendException(event: ExceptionAnalyticsEvent) { + FirebaseCrashlytics.getInstance().recordException(event.exception) + } + }, + ) + @Before fun setup() { mockkStatic(FirebaseCrashlytics::class) @@ -25,7 +36,7 @@ internal class BlockchainProvidersResponseMergerTest { fun test_if_both_configs_are_empty() { val expected = emptyMap>() - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = emptyMap(), remote = emptyMap(), ) @@ -37,7 +48,7 @@ internal class BlockchainProvidersResponseMergerTest { fun test_if_local_config_is_empty() { val expected = remoteResponse - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = emptyMap(), remote = remoteResponse, ) @@ -49,7 +60,7 @@ internal class BlockchainProvidersResponseMergerTest { fun test_if_remote_config_is_empty() { val expected = localResponse - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = localResponse, remote = emptyMap(), ) @@ -61,7 +72,7 @@ internal class BlockchainProvidersResponseMergerTest { fun test_if_both_configs_are_not_empty() { val expected = remoteResponse - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = localResponse, remote = remoteResponse, ) @@ -73,7 +84,7 @@ internal class BlockchainProvidersResponseMergerTest { fun test_if_configs_are_equal() { val expected = remoteResponse - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = remoteResponse, remote = remoteResponse, ) @@ -88,7 +99,7 @@ internal class BlockchainProvidersResponseMergerTest { val expected = localResponseWithEth + remoteResponse - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = localResponseWithEth, remote = remoteResponse, ) @@ -103,7 +114,7 @@ internal class BlockchainProvidersResponseMergerTest { val eth = "ethereum" to emptyList() val remoteWithEth = remoteResponse + eth - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = localResponse, remote = remoteWithEth, ) @@ -121,7 +132,7 @@ internal class BlockchainProvidersResponseMergerTest { */ val expected = remoteResponse - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = localResponse, remote = remoteWithoutLocal, ) @@ -137,7 +148,7 @@ internal class BlockchainProvidersResponseMergerTest { val expected = remoteResponse + ("ethereum" to listOf(nowNodesProvider)) - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = localResponse, remote = remoteWithEth, ) @@ -152,7 +163,7 @@ internal class BlockchainProvidersResponseMergerTest { val expected = remoteResponse - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = localResponse, remote = remoteWithEth, ) @@ -170,7 +181,7 @@ internal class BlockchainProvidersResponseMergerTest { val expected = remoteResponse + eth.addSlash() + kaspa.addSlash() - val actual = BlockchainProvidersResponseMerger.merge( + val actual = blockchainProvidersResponseMerger.merge( local = localWithKaspa, remote = remoteWithEth, ) 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 3b8db3bccf..3decd72093 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 @@ -5,7 +5,6 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.compatibility.l2BlockchainsList import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.blockchainsdk.utils.minimalAmount import com.tangem.lib.crypto.converter.XrpTaggedAddressConverter import com.tangem.lib.crypto.models.XrpTaggedAddress import java.math.BigDecimal @@ -125,7 +124,7 @@ object BlockchainUtils { return l2BlockchainsList.contains(blockchain) } - fun getTezosThreshold(): BigDecimal = Blockchain.Tezos.minimalAmount() + fun getTezosThreshold(): BigDecimal = BigDecimal.ONE.movePointLeft(Blockchain.Tezos.decimals()) /** * Blockchains not affecting total balance counting on errors diff --git a/libs/tangem-sdk-api/build.gradle.kts b/libs/tangem-sdk-api/build.gradle.kts index 577365a357..f780f554c2 100644 --- a/libs/tangem-sdk-api/build.gradle.kts +++ b/libs/tangem-sdk-api/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.visa.models) + implementation(projects.core.configToggles) implementation(projects.core.res) /** Tangem libraries */ diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkFeatureTogglesModule.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkFeatureTogglesModule.kt new file mode 100644 index 0000000000..a7389193ee --- /dev/null +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkFeatureTogglesModule.kt @@ -0,0 +1,18 @@ +package com.tangem.sdk.api.di + +import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles +import com.tangem.sdk.api.featuretoggles.DefaultCardSdkFeatureToggles +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface CardSdkFeatureTogglesModule { + + @Binds + @Singleton + fun bindCardSdkFeatureToggles(impl: DefaultCardSdkFeatureToggles): CardSdkFeatureToggles +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6a6cfde251 --- /dev/null +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt @@ -0,0 +1,17 @@ +package com.tangem.sdk.api.di + +import com.tangem.operations.attestation.OnlineCardVerifier +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object CardSdkModule { + + @Provides + @Singleton + fun provideOnlineCardVerifier(): OnlineCardVerifier = OnlineCardVerifier() +} \ 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 new file mode 100644 index 0000000000..b90f1577cc --- /dev/null +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt @@ -0,0 +1,11 @@ +package com.tangem.sdk.api.featuretoggles + +/** + * Card SDK feature toggles + * +[REDACTED_AUTHOR] + */ +interface CardSdkFeatureToggles { + + val isNewAttestationEnabled: 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 new file mode 100644 index 0000000000..4146304590 --- /dev/null +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt @@ -0,0 +1,12 @@ +package com.tangem.sdk.api.featuretoggles + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import javax.inject.Inject + +internal class DefaultCardSdkFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : CardSdkFeatureToggles { + + override val isNewAttestationEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "NEW_ATTESTATION_ENABLED") +} \ No newline at end of file diff --git a/libs/visa/src/main/kotlin/com/tangem/lib/visa/DefaultVisaContractInfoProvider.kt b/libs/visa/src/main/kotlin/com/tangem/lib/visa/DefaultVisaContractInfoProvider.kt index 395904554e..957b817ab8 100644 --- a/libs/visa/src/main/kotlin/com/tangem/lib/visa/DefaultVisaContractInfoProvider.kt +++ b/libs/visa/src/main/kotlin/com/tangem/lib/visa/DefaultVisaContractInfoProvider.kt @@ -81,7 +81,14 @@ internal class DefaultVisaContractInfoProvider( { fetchBalances(paymentAccount, paymentToken) }, { fetchLimits(paymentAccount, paymentToken, walletAddress) }, { token, balances, (oldLimit, newLimit, changeDate) -> - VisaContractInfo(token, balances, oldLimit, newLimit, changeDate) + VisaContractInfo( + token = token, + balances = balances, + oldLimits = oldLimit, + newLimits = newLimit, + paymentAccountAddress = paymentAccount.contractAddress, + limitsChangeDate = changeDate, + ) }, ) diff --git a/libs/visa/src/main/kotlin/com/tangem/lib/visa/model/VisaContractInfo.kt b/libs/visa/src/main/kotlin/com/tangem/lib/visa/model/VisaContractInfo.kt index e50c6e3c89..b15cc9a7e7 100644 --- a/libs/visa/src/main/kotlin/com/tangem/lib/visa/model/VisaContractInfo.kt +++ b/libs/visa/src/main/kotlin/com/tangem/lib/visa/model/VisaContractInfo.kt @@ -9,6 +9,7 @@ data class VisaContractInfo( val balances: Balances, val oldLimits: Limits, val newLimits: Limits, + val paymentAccountAddress: String, val limitsChangeDate: Instant, ) { 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 a4bc580673..89c5d2f72d 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,25 +69,17 @@ private fun AppExtension.configureBuildTypes() { private fun AndroidBuildType.configureBuildVariant(appExtension: AppExtension, buildType: BuildType) { when (buildType) { - BuildType.Release -> { - isDebuggable = false - } BuildType.Debug -> { isDebuggable = true } - BuildType.DebugPG -> { - // build is not debuggable to let R8 make optimizations - // otherwise, only shrinking will be applied - matchingFallbacks.add(BuildType.Debug.id) - signingConfig = appExtension.signingConfigs.getByName(BuildType.Debug.id) - } - BuildType.Internal, - BuildType.External, - -> { + BuildType.Internal -> { 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 1ca95f8b20..485c960dcb 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 @@ -32,19 +32,6 @@ internal enum class BuildType( ), ), - DebugPG( - id = "debugPG", - appIdSuffix = "debug", - obfuscating = true, - configFields = listOf( - BuildConfigField.Environment(value = "dev"), - BuildConfigField.TestActionEnabled(isEnabled = true), - BuildConfigField.LogEnabled(isEnabled = true), - BuildConfigField.TesterMenuAvailability(isEnabled = true), - BuildConfigField.MockDataSource(isEnabled = false), - ), - ), - /** * Build type for QA and business * @@ -91,27 +78,6 @@ 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 6bda05b59e..ddcc0c9bc5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,7 +47,7 @@ dependencyResolutionManagement { } maven { // setting any repository from tangem project allows maven search all packages in the project - url = uri("https://maven.pkg.github.com/tangem-developments/tangem-sdk-android") + url = uri("https://maven.pkg.github.com/tangem/tangem-sdk-android") credentials { username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") @@ -56,7 +56,7 @@ dependencyResolutionManagement { } maven { // setting any repository from tangem project allows maven search all packages in the project - url = uri("https://maven.pkg.github.com/tangem-developments/blst-android") + url = uri("https://maven.pkg.github.com/tangem/blst-android") credentials { username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") @@ -65,7 +65,7 @@ dependencyResolutionManagement { } maven { // setting any repository from tangem project allows maven search all packages in the project - url = uri("https://maven.pkg.github.com/tangem-developments/blockchain-sdk-kotlin") + url = uri("https://maven.pkg.github.com/tangem/blockchain-sdk-kotlin") credentials { username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") @@ -74,7 +74,7 @@ dependencyResolutionManagement { } maven { // setting any repository from tangem project allows maven search all packages in the project - url = uri("https://maven.pkg.github.com/tangem-developments/wallet-core") + url = uri("https://maven.pkg.github.com/tangem/wallet-core") credentials { username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") @@ -86,7 +86,7 @@ dependencyResolutionManagement { } maven { // setting any repository from tangem project allows maven search all packages in the project - url = uri("https://maven.pkg.github.com/tangem-developments/vico") + url = uri("https://maven.pkg.github.com/tangem/vico") credentials { username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") @@ -97,7 +97,7 @@ dependencyResolutionManagement { } maven { // setting any repository from tangem project allows maven search all packages in the project - url = uri("https://maven.pkg.github.com/tangem-developments/ic4j-agent") + url = uri("https://maven.pkg.github.com/tangem/ic4j-agent") credentials { username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") @@ -134,9 +134,6 @@ dependencyResolutionManagement { enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") -include(":common") -include(":common:ui-charts") -include(":common:routing") // region Core modules include(":core:analytics") @@ -151,11 +148,17 @@ include(":core:deep-links") include(":core:deep-links:global") include(":core:decompose") include(":core:pagination") +include(":core:error") +include(":core:error:ext") // endregion Core modules // region Common modules -include(":common:ui") +include(":common") include(":common:google") +include(":common:routing") +include(":common:test") +include(":common:ui") +include(":common:ui-charts") // endregion // region Libs modules @@ -194,6 +197,8 @@ include(":features:tokendetails:impl") include(":features:send:api") include(":features:send:impl") +include(":features:send-v2:api") +include(":features:send-v2:impl") include(":features:manage-tokens:api") include(":features:manage-tokens:impl") @@ -233,6 +238,9 @@ include(":features:biometry:impl") include(":features:nft:api") include(":features:nft:impl") + +include(":features:walletconnect:api") +include(":features:walletconnect:impl") // endregion Feature modules // region Domain modules @@ -268,6 +276,7 @@ include(":domain:qr-scanning:models") include(":domain:staking") include(":domain:staking:models") include(":domain:wallet-connect") +include(":domain:wallet-connect:models") include(":domain:markets") include(":domain:markets:models") include(":domain:manage-tokens") @@ -275,8 +284,10 @@ include(":domain:manage-tokens:models") include(":domain:onramp") include(":domain:onramp:models") include(":domain:promo") +include(":domain:promo:models") include(":domain:nft") include(":domain:nft:models") +include(":domain:networks") // endregion Domain modules // region Data modules @@ -300,7 +311,7 @@ include(":data:staking") include(":data:wallet-connect") include(":data:markets") include(":data:manage-tokens") +include(":data:networks") include(":data:nft") include(":data:onramp") -// endregion Data modules -include(":domain:promo:models") \ No newline at end of file +// endregion Data modules \ No newline at end of file