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..bb4aa08278 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -1,6 +1,9 @@ package com.tangem.tap import android.app.Application +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import coil.ImageLoader @@ -8,6 +11,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 +50,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 @@ -68,9 +74,7 @@ import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* import org.rekotlin.Store import kotlin.collections.set import com.tangem.tap.domain.walletconnect2.domain.LegacyWalletConnectRepository as WalletConnect2Repository @@ -146,9 +150,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 +183,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 +210,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,14 +221,40 @@ 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 + private val appScope = MainScope() + override fun onCreate() { + enableStrictModeInDebug() super.onCreate() - init() + } - updateLogFiles() + private fun enableStrictModeInDebug() { + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectAll() + .penaltyLog() + .build(), + ) + StrictMode.setVmPolicy( + VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() + .build(), + ) + } } private fun updateLogFiles() { @@ -254,32 +281,48 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat foregroundActivityObserver = ForegroundActivityObserver() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) - // TODO: Try to performance and user experience. - // [REDACTED_JIRA] + // We need to initialize the toggles and excludedBlockchainsManager before the MainActivity starts using them. runBlocking { awaitAll( - async { featureTogglesManager.init() }, - async { excludedBlockchainsManager.init() }, - async { initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) }, + async { + featureTogglesManager.init() + }, + async { + excludedBlockchainsManager.init() + }, ) } - loadNativeLibraries() - // ExceptionHandler.append(blockchainExceptionHandler) // TODO [REDACTED_TASK_KEY] Send only to Firebase + appScope.launch { + initWithConfigDependency(environmentConfig = environmentConfigStorage.initialize()) + launch(Dispatchers.IO) { + loadNativeLibraries() + walletConnect2Repository.init( + projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId, + ) + updateLogFiles() + } + } + + ExceptionHandler.append(blockchainExceptionHandler) + if (LogConfig.network.blockchainSdkNetwork) { BlockchainSdkRetrofitBuilder.interceptors = listOf( createNetworkLoggingInterceptor(), ChuckerInterceptor(this), ) + TangemApiServiceLogging.addInterceptors( + createNetworkLoggingInterceptor(), + ChuckerInterceptor(this), + ) } derivationsFinder = DerivationsFinder( appPreferencesStore = appPreferencesStore, dispatchers = dispatchers, ) - appStateHolder.mainStore = store - walletConnect2Repository.init(projectId = environmentConfigStorage.getConfigSync().walletConnectProjectId) + appStateHolder.mainStore = store } private fun createReduxStore(): Store { @@ -301,7 +344,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat generalUserWalletsListManager = generalUserWalletsListManager, wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, - generateWalletNameUseCase = generateWalletNameUseCase, cardRepository = cardRepository, settingsRepository = settingsRepository, blockchainSDKFactory = blockchainSDKFactory, @@ -312,7 +354,6 @@ abstract class TangemApplication : Application(), ImageLoaderFactory, Configurat shareManager = shareManager, appRouter = appRouter, transactionSignerFactory = transactionSignerFactory, - getUserCountryUseCase = getUserCountryUseCase, onrampFeatureToggles = onrampFeatureToggles, environmentConfigStorage = environmentConfigStorage, onboardingV2FeatureToggles = onboardingV2FeatureToggles, @@ -322,6 +363,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/DefaultVisaAuthTokenStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt index a6128ddfe6..9859e3e4be 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultVisaAuthTokenStorage.kt @@ -20,18 +20,22 @@ internal class DefaultVisaAuthTokenStorage @Inject constructor( private val dispatcherProvider: CoroutineDispatcherProvider, ) : VisaAuthTokenStorage { - private val secureStorage = AndroidSecureStorage( - preferences = SecureStorage.createEncryptedSharedPreferences( - context = applicationContext, - storageName = "visa_auth_storage", - ), - ) + private val secureStorage by lazy { + AndroidSecureStorage( + preferences = SecureStorage.createEncryptedSharedPreferences( + context = applicationContext, + storageName = "visa_auth_storage", + ), + ) + } - private val moshi = Moshi.Builder() - .add(KotlinJsonAdapterFactory()) - .build() + private val moshi by lazy { + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + } - private val tokensAdapter = moshi.adapter(VisaAuthTokens::class.java) + private val tokensAdapter by lazy { moshi.adapter(VisaAuthTokens::class.java) } override suspend fun store(cardId: String, tokens: VisaAuthTokens) = withContext(dispatcherProvider.io) { val json = tokensAdapter.toJson(tokens) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt b/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt index f752d10dea..b87d3fbfdf 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultVisaOTPStorage.kt @@ -20,12 +20,14 @@ class DefaultVisaOTPStorage @Inject constructor( private val dispatcherProvider: CoroutineDispatcherProvider, ) : VisaOTPStorage { - private val secureStorage = AndroidSecureStorage( - preferences = SecureStorage.createEncryptedSharedPreferences( - context = applicationContext, - storageName = "visa_otp_storage", - ), - ) + private val secureStorage by lazy { + AndroidSecureStorage( + preferences = SecureStorage.createEncryptedSharedPreferences( + context = applicationContext, + storageName = "visa_otp_storage", + ), + ) + } override suspend fun saveOTP(cardId: String, data: VisaOtpData) = withContext(dispatcherProvider.io) { secureStorage.store(data.rootOTP, VISA_ROOT_OTP_KEY_PREFIX + cardId) diff --git a/app/src/main/java/com/tangem/tap/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..4096521b75 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -10,7 +10,12 @@ 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.operations.attestation.CardArtworksProvider +import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -18,6 +23,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 +171,30 @@ internal object WalletsDomainModule { fun providesSeedPhraseNotificationUseCase(walletsRepository: WalletsRepository): SeedPhraseNotificationUseCase { return SeedPhraseNotificationUseCase(walletsRepository = walletsRepository) } + + @Provides + @Singleton + fun provideGetCardImageUseCase( + onlineCardVerifier: OnlineCardVerifier, + cardArtworksProvider: CardArtworksProvider, + cardSdkFeatureToggles: CardSdkFeatureToggles, + ): GetCardImageUseCase { + return GetCardImageUseCase( + verifier = onlineCardVerifier, + cardArtworksProvider = cardArtworksProvider, + cardSdkFeatureToggles = cardSdkFeatureToggles, + ) + } + + @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..547dfbd6ce 100644 --- a/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt +++ b/app/src/main/java/com/tangem/tap/domain/extensions/Card.kt @@ -6,14 +6,18 @@ 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.CardArtworksProvider 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,13 +32,13 @@ suspend fun CardDTO.getOrLoadCardArtworkUrl(cardInfo: Result { val artworkId = cardInfoResult.data.artwork?.id if (artworkId.isNullOrEmpty()) { ifAnyError() } else { - OnlineCardVerifier.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId) + CardArtworksProvider.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId) } } diff --git a/app/src/main/java/com/tangem/tap/domain/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..adc8e80741 100644 --- a/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt +++ b/app/src/main/java/com/tangem/tap/domain/tokens/DefaultTokensFeatureToggles.kt @@ -7,6 +7,12 @@ 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") + + override val isQuotesLoadingRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "QUOTES_LOADING_REFACTORING_ENABLED") + + override val isStakingLoadingRefactoringEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "STAKING_LOADING_REFACTORING_ENABLED") } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt index e7a9f1873f..95e75741b7 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/model/UserWalletInformation.kt @@ -21,8 +21,6 @@ internal data class UserWalletPublicInformation( val name: String, @Json(name = "walletId") val walletId: UserWalletId, - @Json(name = "artworkUrl") - val artworkUrl: String, @Json(name = "cardsInWallet") val cardsInWallet: Set, @Json(name = "scanResponse") diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt index 9ee2498fa8..d963dc5794 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsPublicInformationRepository.kt @@ -20,9 +20,12 @@ internal class DefaultUserWalletsPublicInformationRepository( moshi: Moshi, private val secureStorage: SecureStorage, ) : UserWalletsPublicInformationRepository { - private val publicInformationAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java), - ) + + private val publicInformationAdapter: JsonAdapter> by lazy { + moshi.adapter( + Types.newParameterizedType(List::class.java, UserWalletPublicInformation::class.java), + ) + } override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult { return withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt index 13c56f4f22..a1888ea494 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/implementation/DefaultUserWalletsSensitiveInformationRepository.kt @@ -24,12 +24,15 @@ internal class DefaultUserWalletsSensitiveInformationRepository( private val secureStorage: SecureStorage, ) : UserWalletsSensitiveInformationRepository { - private val sensitiveInformationAdapter: JsonAdapter = moshi.adapter( - UserWalletSensitiveInformation::class.java, - ) - private val encryptedSensitiveInformationMapAdapter: JsonAdapter> = moshi.adapter( - Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java), - ) + private val sensitiveInformationAdapter: JsonAdapter by lazy { + moshi.adapter(UserWalletSensitiveInformation::class.java) + } + + private val encryptedSensitiveInformationMapAdapter: JsonAdapter> by lazy { + moshi.adapter( + Types.newParameterizedType(Map::class.java, String::class.java, ByteArray::class.java), + ) + } override suspend fun save(userWallet: UserWallet, encryptionKey: ByteArray?): CompletionResult { if (encryptionKey == null) { diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt index 4c73101416..8dca07bec0 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/utils/Mapper.kt @@ -15,7 +15,6 @@ internal val UserWallet.publicInformation: UserWalletPublicInformation get() = UserWalletPublicInformation( name = name, walletId = walletId, - artworkUrl = artworkUrl, cardsInWallet = cardsInWallet, isMultiCurrency = isMultiCurrency, scanResponse = scanResponse.copy( @@ -31,7 +30,6 @@ internal fun UserWalletPublicInformation.toUserWallet(): UserWallet { return UserWallet( name = name, walletId = walletId, - artworkUrl = artworkUrl, cardsInWallet = cardsInWallet, scanResponse = scanResponse, isMultiCurrency = isMultiCurrency, diff --git a/app/src/main/java/com/tangem/tap/domain/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..fe64265670 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt @@ -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 @@ -32,7 +31,7 @@ import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.onboarding.data.model.CreateWalletResponse import com.tangem.feature.onboarding.presentation.wallet2.analytics.SeedPhraseSource import com.tangem.feature.wallet.presentation.wallet.domain.BackupValidator -import com.tangem.operations.attestation.OnlineCardVerifier +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.CreateProductWalletTaskResponse import com.tangem.sdk.extensions.localizedDescriptionRes @@ -243,13 +242,15 @@ 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()) { defaultArtwork ?: Uri.EMPTY } else { - Uri.parse(OnlineCardVerifier.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)) + Uri.parse(CardArtworksProvider.getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId)) } } is Result.Failure -> defaultArtwork ?: Uri.EMPTY @@ -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..9d8ccb0fbb 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -26,13 +26,14 @@ 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.CardArtworksProvider +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 +57,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 +67,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 +76,7 @@ data class DaggerGraphState( val clipboardManager: ClipboardManager? = null, val settingsManager: SettingsManager? = null, val uiMessageSender: UiMessageSender? = null, + val onlineCardVerifier: OnlineCardVerifier? = null, + val cardArworksProvider: CardArtworksProvider? = null, + val userWalletBuilderFactory: UserWalletBuilder.Factory? = null, ) : StateType \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/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..005557d07b 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,22 @@ import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.markets.details.MarketsTokenDetailsComponent +import com.tangem.features.nft.component.NFTCollectionsComponent +import com.tangem.features.nft.component.NFTDetailsComponent +import com.tangem.features.nft.component.NFTReceiveComponent +import com.tangem.features.nft.component.NFTAssetTraitsComponent 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.NFTSendComponent +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 +48,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 +82,43 @@ 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 nftAssetTraitsComponentFactory: NFTAssetTraitsComponent.Factory, + private val nftSendComponentFactory: NFTSendComponent.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 +131,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 +150,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 +174,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 +185,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 +231,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 +242,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 +252,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 +263,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 +275,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 +378,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 +390,64 @@ 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, + nftCollectionName = route.collectionName, + ), + componentFactory = nftDetailsComponentFactory, + ) + is AppRoute.NFTAssetTraits -> + createComponentChild( + context = context, + params = NFTAssetTraitsComponent.Params(nftAsset = route.nftAsset), + componentFactory = nftAssetTraitsComponentFactory, + ) + is AppRoute.NFTSend -> { + createComponentChild( + context = context, + params = NFTSendComponent.Params( + userWalletId = route.userWalletId, + nftAsset = route.nftAsset, + nftCollectionName = route.nftCollectionName, + ), + componentFactory = nftSendComponentFactory, + ) + } is AppRoute.OnboardingNote, is AppRoute.SaveWallet, is AppRoute.OnboardingOther, @@ -401,18 +485,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 +583,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 +636,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 +758,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 +782,45 @@ 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, + nftCollectionName = route.collectionName, + ), + componentFactory = nftDetailsComponentFactory, + ) + is AppRoute.NFTAssetTraits -> + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = NFTAssetTraitsComponent.Params(nftAsset = route.nftAsset), + componentFactory = nftAssetTraitsComponentFactory, + ) + is AppRoute.NFTSend -> { + route.asComponentChild( + contextProvider = contextProvider(route, contextFactory), + params = NFTSendComponent.Params( + userWalletId = route.userWalletId, + nftAsset = route.nftAsset, + nftCollectionName = route.nftCollectionName, + ), + componentFactory = nftSendComponentFactory, + ) + } } // 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..7b9bd90813 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/card/DefaultDerivationsRepositoryTest.kt @@ -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 @@ -37,10 +39,9 @@ internal class DefaultDerivationsRepositoryTest { private val defaultUserWallet = UserWallet( name = "", walletId = defaultUserWalletId, - 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 +78,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 +95,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 +106,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 +120,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 +128,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 +143,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 +154,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..209dcbb443 100644 --- a/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManagerTest.kt @@ -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 @@ -194,11 +194,10 @@ internal class BiometricUserWalletsListManagerTest(private val model: Model) { return UserWallet( name = "Wallet $id", walletId = UserWalletId(stringValue = id), - artworkUrl = "", 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..a21f44aa02 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,33 @@ 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, + val collectionName: String, + ) : AppRoute(path = "/nft_details/${userWalletId.stringValue}/${nftAsset.collectionId}/${nftAsset.id.stringValue}") + + @Serializable + data class NFTAssetTraits( + val nftAsset: NFTAsset, + ) : AppRoute(path = "/nft_traits/${nftAsset.collectionId}/${nftAsset.id.stringValue}") + + @Serializable + data class NFTSend( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val nftCollectionName: String, + ) : AppRoute(path = "/send/nft/${userWalletId.stringValue}/$nftCollectionName/${nftAsset.id}") } \ 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..4ab99487fb --- /dev/null +++ b/common/test/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.common.test" +} + +dependencies { + implementation(projects.core.datasource) + implementation(projects.core.utils) + + 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/data/quote/MockQuoteResponseFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/quote/MockQuoteResponseFactory.kt new file mode 100644 index 0000000000..2e3f56492e --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/quote/MockQuoteResponseFactory.kt @@ -0,0 +1,19 @@ +package com.tangem.common.test.data.quote + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +object MockQuoteResponseFactory { + + fun createSinglePrice(value: BigDecimal): QuotesResponse.Quote { + return QuotesResponse.Quote( + price = value, + priceChange24h = value, + priceChange1w = value, + priceChange30d = value, + ) + } +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt b/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt new file mode 100644 index 0000000000..ac570f6d81 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/quote/QuoteResponseExt.kt @@ -0,0 +1,14 @@ +package com.tangem.common.test.data.quote + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.local.quote.converter.QuoteConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Quote + +fun QuotesResponse.Quote.toDomain(rawCurrencyId: String, source: StatusSource = StatusSource.ACTUAL): Quote { + return QuoteConverter(source = source).convert(value = mapOf(rawCurrencyId to this).entries.first()) +} + +fun Pair.toDomain(source: StatusSource = StatusSource.ACTUAL): Quote { + return QuoteConverter(source = source).convert(value = mapOf(this).entries.first()) +} \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/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..4c16726aa0 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/domain/wallet/MockUserWalletFactory.kt @@ -0,0 +1,32 @@ +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", + 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/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt index c4a34ce6ee..b0d7438548 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/UserWalletItem.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.CardColors import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -61,7 +61,7 @@ fun UserWalletItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { - CardImage(imageUrl = state.imageUrl) + CardImage(state.imageState) NameAndInfo( modifier = Modifier.weight(1f), name = state.name, @@ -147,39 +147,49 @@ private fun NameAndInfo( } @Composable -private fun CardImage(imageUrl: String, modifier: Modifier = Modifier) { +private fun CardImage(imageState: UserWalletItemUM.ImageState, modifier: Modifier = Modifier) { val imageModifier = modifier .width(TangemTheme.dimens.size24) .height(TangemTheme.dimens.size36) .clip(TangemTheme.shapes.roundedCornersSmall) - SubcomposeAsyncImage( - modifier = imageModifier, - model = ImageRequest.Builder(LocalContext.current) - .transformations(RotationTransformation(angle = 90f)) - .size( - width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }, - height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() }, - ) - .data(imageUrl) - .crossfade(enable = true) - .allowHardware(enable = false) - .build(), - loading = { + when (imageState) { + is UserWalletItemUM.ImageState.Loading -> { RectangleShimmer( modifier = imageModifier, radius = TangemTheme.dimens.size2, ) - }, - error = { - Image( + } + is UserWalletItemUM.ImageState.Image -> { + SubcomposeAsyncImage( modifier = imageModifier, - imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36), + model = ImageRequest.Builder(LocalContext.current) + .transformations(RotationTransformation(angle = 90f)) + .size( + width = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() }, + height = with(LocalDensity.current) { TangemTheme.dimens.size24.roundToPx() }, + ) + .data(imageState.artwork.verifiedArtwork?.toByteArray() ?: imageState.artwork.defaultUrl) + .crossfade(enable = true) + .allowHardware(enable = false) + .build(), + loading = { + RectangleShimmer( + modifier = imageModifier, + radius = TangemTheme.dimens.size2, + ) + }, + error = { + Image( + modifier = imageModifier, + imageVector = ImageVector.vectorResource(R.drawable.img_card_wallet_2_gray_22_36), + contentDescription = null, + ) + }, contentDescription = null, ) - }, - contentDescription = null, - ) + } + } } @Composable @@ -215,7 +225,6 @@ private class UserWalletItemUMPreviewProvider : PreviewParameterProvider { override fun convert(value: UserWallet): UserWalletItemUM { @@ -40,10 +43,12 @@ class UserWalletItemUMConverter( name = stringReference(name), information = getInfo(userWallet = this), balance = getBalanceInfo(userWallet = this), - imageUrl = artworkUrl, isEnabled = !isLocked, endIcon = endIcon, onClick = { onClick(value.walletId) }, + imageState = artwork?.let { + UserWalletItemUM.ImageState.Image(ArtworkUM(it.verifiedArtwork, it.defaultUrl)) + } ?: UserWalletItemUM.ImageState.Loading, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt index 04b6329ce9..a986ba5b62 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/userwallet/state/UserWalletItemUM.kt @@ -1,5 +1,6 @@ package com.tangem.common.ui.userwallet.state +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.wallets.models.UserWalletId import javax.annotation.concurrent.Immutable @@ -10,7 +11,7 @@ data class UserWalletItemUM( val name: TextReference, val information: TextReference, val balance: Balance, - val imageUrl: String, + val imageState: ImageState = ImageState.Loading, val isEnabled: Boolean, val endIcon: EndIcon = EndIcon.None, val onClick: () -> Unit, @@ -36,4 +37,14 @@ data class UserWalletItemUM( val isFlickering: Boolean, ) : Balance() } + + @Immutable + sealed class ImageState { + + data object Loading : ImageState() + + data class Image( + val artwork: ArtworkUM, + ) : ImageState() + } } \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/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/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt index 5d8c3a6887..879b87a6f9 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt @@ -22,9 +22,35 @@ sealed class MainScreenAnalyticsEvent( ) // region Action Buttons feature - data class ButtonBuy(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent( + data class ButtonBuy( + val status: AnalyticsParam.Status, + val screenType: String? = null, + ) : MainScreenAnalyticsEvent( event = "Button - Buy", - params = mapOf(AnalyticsParam.STATUS to status.value), + params = buildMap { + put(AnalyticsParam.STATUS, status.value) + screenType?.let { put(AnalyticsParam.TYPE, it) } + }, + ) + + data object ButtonReceive : MainScreenAnalyticsEvent( + event = "Button - Receive", + ) + + data object LimitsClicked : MainScreenAnalyticsEvent( + event = "Limits Clicked", + ) + + data object NoticeBalancesInfo : MainScreenAnalyticsEvent( + event = "Notice - Balances Info", + ) + + data object NoticeLimitsInfo : MainScreenAnalyticsEvent( + event = "Notice - Limits Info", + ) + + data object ButtonExplore : MainScreenAnalyticsEvent( + event = "Button - Explore", ) data class ButtonSwap(val status: AnalyticsParam.Status) : MainScreenAnalyticsEvent( @@ -83,4 +109,9 @@ sealed class MainScreenAnalyticsEvent( params = mapOf(ERROR_CODE to errorCode), ) // endregion + + companion object { + const val VISA_TYPE = "Visa" + const val WALLET_TYPE = "Wallet" + } } \ No newline at end of file diff --git a/core/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..66d5de0e26 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,38 @@ }, { "name": "NOTE_REFACTORING_ENABLED", + "version": "5.23.0" + }, + { + "name": "NEW_ARTWORK_LOADING", + "version": "5.24.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" + }, + { + "name": "QUOTES_LOADING_REFACTORING_ENABLED", + "version": "5.24.0" + }, + { + "name": "STAKING_LOADING_REFACTORING_ENABLED", "version": "undefined" } ] diff --git a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt index 750dbada43..7aca4511ea 100644 --- a/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt +++ b/core/config-toggles/src/main/kotlin/com/tangem/core/configtoggle/feature/impl/DevFeatureTogglesManager.kt @@ -32,7 +32,7 @@ internal class DevFeatureTogglesManager( val savedFeatureToggles = appPreferencesStore.getObjectSyncOrNull>( key = PreferencesKeys.FEATURE_TOGGLES_KEY, - ) ?: emptyMap() + ) ?: emptyMap() val localFeatureToggles = localTogglesStorage.toggles .associateToggles(currentVersion = versionProvider.get().orEmpty()) diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt index b1822c84d9..a4c3d6d7aa 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/manager/DevTogglesManagerTest.kt @@ -2,6 +2,7 @@ package com.tangem.core.configtoggle.manager import android.annotation.SuppressLint import com.google.common.truth.Truth +import com.squareup.moshi.Moshi import com.tangem.core.configtoggle.feature.impl.DevFeatureTogglesManager import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants import com.tangem.core.configtoggle.storage.ConfigToggle @@ -12,6 +13,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.Test @@ -24,7 +26,11 @@ import kotlin.collections.set internal class DevTogglesManagerTest { private val localTogglesStorage = mockk() - private val appPreferenceStore = mockk(relaxed = true) + private val appPreferenceStore = AppPreferencesStore( + moshi = Moshi.Builder().build(), + dispatchers = TestingCoroutineDispatcherProvider(), + preferencesDataStore = mockk(relaxed = true), + ) private val versionProvider = mockk() private val manager = DevFeatureTogglesManager( localTogglesStorage = localTogglesStorage, diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt index 4a8e6550b8..b2f5bc4be7 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/storage/LocalTogglesStorageTest.kt @@ -8,6 +8,7 @@ import com.squareup.moshi.Types import com.tangem.core.configtoggle.feature.impl.FeatureTogglesConstants import com.tangem.datasource.asset.loader.AssetLoader import com.tangem.datasource.asset.reader.AssetReader +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.Test @@ -24,7 +25,11 @@ internal class LocalTogglesStorageTest { private val jsonAdapter = mockk>>() // Impossible to mockk AssetLoader because it implement inline functions - private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi) + private val assetLoader = AssetLoader( + assetReader = assetReader, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) private val storage = LocalTogglesStorage(assetLoader) diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 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/api/visa/models/response/CardActivationRemoteStateResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt index e0ee6cc4f0..aeb498dac8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/visa/models/response/CardActivationRemoteStateResponse.kt @@ -7,10 +7,13 @@ import com.squareup.moshi.JsonClass data class CardActivationRemoteStateResponse( @Json(name = "activation_status") val status: String, @Json(name = "activation_order") val activationOrder: ActivationOrder?, + @Json(name = "stepChangeCode") val stepChangeCode: Int?, + @Json(name = "updatedAt") val updatedAt: String?, ) { @JsonClass(generateAdapter = true) data class ActivationOrder( @Json(name = "id") val id: String, + @Json(name = "customer_id") val customerId: String, @Json(name = "customer_wallet_address") val customerWalletAddress: String, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt new file mode 100644 index 0000000000..5f9e2c12a9 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/AppCurrencyResponseStore.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.appcurrency + +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import kotlinx.coroutines.flow.Flow + +/** + * Store of app currency data model [CurrenciesResponse.Currency] + * +[REDACTED_AUTHOR] + */ +interface AppCurrencyResponseStore { + + /** Get flow of [CurrenciesResponse.Currency] */ + fun get(): Flow + + /** Get [CurrenciesResponse.Currency] synchronously or null */ + suspend fun getSyncOrNull(): CurrenciesResponse.Currency? +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt new file mode 100644 index 0000000000..1c268c06c7 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/appcurrency/DefaultAppCurrencyResponseStore.kt @@ -0,0 +1,28 @@ +package com.tangem.datasource.appcurrency + +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObject +import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull +import kotlinx.coroutines.flow.Flow + +/** + * Default implementation of [AppCurrencyResponseStore] + * + * @property appPreferencesStore app preferences store + */ +internal class DefaultAppCurrencyResponseStore( + private val appPreferencesStore: AppPreferencesStore, +) : AppCurrencyResponseStore { + + override fun get(): Flow { + return appPreferencesStore.getObject(PreferencesKeys.SELECTED_APP_CURRENCY_KEY) + } + + override suspend fun getSyncOrNull(): CurrenciesResponse.Currency? { + return appPreferencesStore.getObjectSyncOrNull( + PreferencesKeys.SELECTED_APP_CURRENCY_KEY, + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt b/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt index 6c04362a3c..3f47419a51 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/asset/loader/AssetLoader.kt @@ -3,8 +3,10 @@ package com.tangem.datasource.asset.loader import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.squareup.moshi.adapter +import com.tangem.utils.coroutines.runCatching import com.tangem.datasource.asset.reader.AssetReader import com.tangem.datasource.di.NetworkMoshi +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -23,69 +25,63 @@ import javax.inject.Singleton class AssetLoader @Inject constructor( val assetReader: AssetReader, @NetworkMoshi val moshi: Moshi, + val dispatchers: CoroutineDispatcherProvider, ) { - /** Load content [Content] of asset file [fileName] */ @OptIn(ExperimentalStdlibApi::class) - suspend inline fun load(fileName: String): Content? { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun load(fileName: String): Content? = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - moshi.adapter().fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - null - }, - ) + moshi.adapter().fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + null + }, + ) /** Load list [V] values of asset file [fileName] */ - suspend inline fun loadList(fileName: String): List { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun loadList(fileName: String): List = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - val type = Types.newParameterizedType(List::class.java, V::class.java) - val adapter = moshi.adapter>(type) + val type = Types.newParameterizedType(List::class.java, V::class.java) + val adapter = moshi.adapter>(type) - adapter.fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig.orEmpty() - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - emptyList() - }, - ) + adapter.fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig.orEmpty() + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + emptyList() + }, + ) /** Load map [String] keys and [V] values of asset file [fileName] */ - suspend inline fun loadMap(fileName: String): Map { - return runCatching { - val json = assetReader.read(fullFileName = "$fileName.json") + suspend inline fun loadMap(fileName: String): Map = runCatching(dispatchers.io) { + val json = assetReader.read(fullFileName = "$fileName.json") - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - adapter.fromJson(json) - } - .fold( - onSuccess = { parsedConfig -> - if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) - parsedConfig.orEmpty() - }, - onFailure = { - Timber.e(it, "Failed to load config [$fileName] from assets") - emptyMap() - }, - ) + adapter.fromJson(json) } + .fold( + onSuccess = { parsedConfig -> + if (parsedConfig == null) Timber.e(IllegalStateException("Parsed config [$fileName] is null")) + parsedConfig.orEmpty() + }, + onFailure = { + Timber.e(it, "Failed to load config [$fileName] from assets") + emptyMap() + }, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt b/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt index 139e082aa9..3847d29980 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/asset/reader/AndroidAssetReader.kt @@ -1,23 +1,19 @@ package com.tangem.datasource.asset.reader import android.content.res.AssetManager -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext import java.io.BufferedReader /** * Implementation of asset file reader * * @property assetManager asset manager - * @property dispatchers dispatchers */ internal class AndroidAssetReader( private val assetManager: AssetManager, - private val dispatchers: CoroutineDispatcherProvider, ) : AssetReader { - override suspend fun read(fullFileName: String): String = withContext(dispatchers.io) { - assetManager.open(fullFileName).bufferedReader() + override suspend fun read(fullFileName: String): String { + return assetManager.open(fullFileName).bufferedReader() .use(BufferedReader::readText) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index 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/AppCurrencyDataModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt index fade673123..70fb61dbea 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppCurrencyDataModule.kt @@ -1,8 +1,11 @@ package com.tangem.datasource.di +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.datasource.appcurrency.DefaultAppCurrencyResponseStore import com.tangem.datasource.local.appcurrency.AvailableAppCurrenciesStore import com.tangem.datasource.local.appcurrency.implementation.DefaultAvailableAppCurrenciesStore import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.preferences.AppPreferencesStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,4 +21,10 @@ internal object AppCurrencyDataModule { fun provideAvailableAppCurrenciesStore(): AvailableAppCurrenciesStore { return DefaultAvailableAppCurrenciesStore(dataStore = RuntimeDataStore()) } + + @Provides + @Singleton + fun provideAppCurrencyResponseStore(appPreferencesStore: AppPreferencesStore): AppCurrencyResponseStore { + return DefaultAppCurrencyResponseStore(appPreferencesStore) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt index bc295ea874..d572234840 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt @@ -2,7 +2,6 @@ package com.tangem.datasource.di import android.content.Context import com.squareup.moshi.Moshi -import com.tangem.datasource.local.* import com.tangem.datasource.local.preferences.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -26,6 +25,7 @@ internal object AppPreferencesStoreModule { return AppPreferencesStore( preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io), moshi = moshi, + dispatchers = dispatchers, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt index 196ec8d836..3075e3f403 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AssetReaderModule.kt @@ -3,7 +3,6 @@ package com.tangem.datasource.di import android.content.Context import com.tangem.datasource.asset.reader.AndroidAssetReader import com.tangem.datasource.asset.reader.AssetReader -import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -17,10 +16,7 @@ internal object AssetReaderModule { @Singleton @Provides - fun providesAsserReader( - @ApplicationContext context: Context, - dispatchers: CoroutineDispatcherProvider, - ): AssetReader { - return AndroidAssetReader(context.assets, dispatchers) + fun providesAsserReader(@ApplicationContext context: Context): AssetReader { + return AndroidAssetReader(context.assets) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index 700b510390..fad4677392 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -51,12 +51,14 @@ class MoshiModule { PolymorphicJsonAdapterFactory.of(NFTCollection.Identifier::class.java, "bc") .withSubtype(NFTCollection.Identifier.EVM::class.java, "evm") .withSubtype(NFTCollection.Identifier.TON::class.java, "ton") + .withSubtype(NFTCollection.Identifier.Solana::class.java, "sol") .withDefaultValue(NFTCollection.Identifier.Unknown), ) .add( PolymorphicJsonAdapterFactory.of(NFTAsset.Identifier::class.java, "bc") .withSubtype(NFTAsset.Identifier.EVM::class.java, "evm") .withSubtype(NFTAsset.Identifier.TON::class.java, "ton") + .withSubtype(NFTAsset.Identifier.Solana::class.java, "sol") .withDefaultValue(NFTAsset.Identifier.Unknown), ) .addLast(KotlinJsonAdapterFactory()) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 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..56c6e52028 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,14 @@ 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 logFile by lazy { + File(applicationContext.filesDir, PERMITTED_FILE_NAME) + } + private val logFileZip by lazy { + File(applicationContext.filesDir, PERMITTED_FILE_NAME_ZIP) + } private val formatter = DateTimeFormatterBuilder() .appendDayOfMonth(2) @@ -56,7 +59,17 @@ class AppLogsStore @Inject constructor( .toFormatter() /** Get log file */ - fun getFile(): File? = if (file.exists()) file else null + fun getFile(): File? = if (logFile.exists()) logFile else null + + suspend fun getZipFile(): File? { + return zipMutex.withLock { + if (logFile.exists()) { + zip(listOf(logFile), logFileZip) + } else { + null + } + } + } /** Save log [message] */ fun saveLogMessage(tag: String, message: String) { @@ -89,8 +102,8 @@ class AppLogsStore @Inject constructor( /** Delete deprecated logs if file size exceeds [maxSize] */ fun deleteDeprecatedLogs(maxSize: Int) { launchWithLock { - if (file.exists() && file.length() > maxSize) { - file.delete() + if (logFile.exists() && logFile.length() > maxSize) { + logFile.delete() } } } @@ -108,7 +121,7 @@ class AppLogsStore @Inject constructor( } private fun writeMessage(tag: String, vararg messages: String) { - BufferedWriter(FileWriter(file, true)).use { writer -> + BufferedWriter(FileWriter(logFile, true)).use { writer -> writer.append(formatter.print(DateTime.now())) writer.append(": $tag ") messages.forEach(writer::append) @@ -117,8 +130,8 @@ class AppLogsStore @Inject constructor( } private fun createFileIfNotExist() { - if (!file.exists()) { - runCatching { file.createNewFile() } + if (!logFile.exists()) { + runCatching { logFile.createNewFile() } .onFailure(Timber::e) } } @@ -133,8 +146,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/NFTSdkAssetIdentifierConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt index b6a045de7e..090e711d37 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkAssetIdentifierConverter.kt @@ -13,6 +13,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter NFTAsset.Identifier.TON( tokenAddress = value.tokenAddress, ) + is SdkNFTAsset.Identifier.Solana -> NFTAsset.Identifier.Solana( + tokenAddress = value.tokenAddress, + cnft = value.cnft, + ) is SdkNFTAsset.Identifier.Unknown -> NFTAsset.Identifier.Unknown } @@ -24,6 +28,10 @@ object NFTSdkAssetIdentifierConverter : TwoWayConverter SdkNFTAsset.Identifier.TON( tokenAddress = value.tokenAddress, ) + is NFTAsset.Identifier.Solana -> SdkNFTAsset.Identifier.Solana( + tokenAddress = value.tokenAddress, + cnft = value.cnft, + ) is NFTAsset.Identifier.Unknown -> SdkNFTAsset.Identifier.Unknown } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/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/nft/converter/NFTSdkCollectionIdentifierConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt index 98d8e0ee73..44f1df3f89 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/nft/converter/NFTSdkCollectionIdentifierConverter.kt @@ -12,6 +12,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter NFTCollection.Identifier.TON( contractAddress = value.contractAddress, ) + is SdkNFTCollection.Identifier.Solana -> NFTCollection.Identifier.Solana( + collection = value.collection, + ) is SdkNFTCollection.Identifier.Unknown -> NFTCollection.Identifier.Unknown } @@ -22,6 +25,9 @@ object NFTSdkCollectionIdentifierConverter : TwoWayConverter SdkNFTCollection.Identifier.TON( contractAddress = value.contractAddress, ) + is NFTCollection.Identifier.Solana -> SdkNFTCollection.Identifier.Solana( + collection = value.collection, + ) is NFTCollection.Identifier.Unknown -> SdkNFTCollection.Identifier.Unknown } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt index 905fe4f767..d30b6e20ef 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.squareup.moshi.Moshi import com.squareup.moshi.Types +import com.tangem.utils.coroutines.CoroutineDispatcherProvider /** * Application preferences store. @@ -19,6 +20,7 @@ import com.squareup.moshi.Types */ class AppPreferencesStore( val moshi: Moshi, + val dispatchers: CoroutineDispatcherProvider, private val preferencesDataStore: DataStore, ) : DataStore by preferencesDataStore { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt index 0369cf22cd..647ff92c89 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/AppPreferencesStoreExt.kt @@ -5,23 +5,25 @@ import androidx.datastore.preferences.core.edit import com.squareup.moshi.JsonDataException import com.squareup.moshi.Types import com.tangem.datasource.local.preferences.AppPreferencesStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.withContext /** Get flow of nullable data [T] by string [key] */ inline fun AppPreferencesStore.getObject(key: Preferences.Key): Flow { - val adapter = moshi.adapter(T::class.java) - return data.map { preferences -> - preferences[key]?.let { - try { - adapter.fromJson(it) - } catch (e: JsonDataException) { - null - } - } - }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter(T::class.java) + emitAll( + data.map { preferences -> + preferences[key]?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + null + } + } + }.distinctUntilChanged(), + ) + } } /** @@ -32,16 +34,19 @@ inline fun AppPreferencesStore.getObject(key: Preferences.Key AppPreferencesStore.getObject(key: Preferences.Key, default: T): Flow { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - return data.map { - try { - it[key]?.let(adapter::fromJson) ?: default - } catch (e: JsonDataException) { - default - } - }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter(T::class.java) + emitAll( + data.map { + try { + it[key]?.let(adapter::fromJson) ?: default + } catch (e: JsonDataException) { + default + } + }.distinctUntilChanged(), + ) + } } - /** * Get nullable data [T] by string [key] * @@ -49,26 +54,27 @@ inline fun AppPreferencesStore.getObject(key: Preferences.Key AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key): T? { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - return data.firstOrNull() - ?.get(key) - ?.let { - try { - adapter.fromJson(it) - } catch (e: JsonDataException) { - null +suspend inline fun AppPreferencesStore.getObjectSyncOrNull(key: Preferences.Key): T? = + withContext(dispatchers.io) { + val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types + data.firstOrNull() + ?.get(key) + ?.let { + try { + adapter.fromJson(it) + } catch (e: JsonDataException) { + null + } } - } -} + } /** Get data [T] by string [key]. If data is not found, it returns [default] */ suspend inline fun AppPreferencesStore.getObjectSyncOrDefault( key: Preferences.Key, default: T, -): T { +): T = withContext(dispatchers.io) { val adapter = moshi.adapter(T::class.java) - return data.firstOrNull() + data.firstOrNull() ?.get(key) ?.let { try { @@ -87,37 +93,47 @@ suspend inline fun AppPreferencesStore.getObjectSyncOrDefault( * * @see storeObjectList * */ -suspend inline fun AppPreferencesStore.storeObject(key: Preferences.Key, value: T) { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types - edit { it[key] = adapter.toJson(value) } -} +@Suppress("OptionalUnit") +suspend inline fun AppPreferencesStore.storeObject(key: Preferences.Key, value: T): Unit = + withContext(dispatchers.io) { + val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types + edit { it[key] = adapter.toJson(value) } + } /** Store list of data [value] by string [key] */ -suspend inline fun AppPreferencesStore.storeObjectList(key: Preferences.Key, value: List) { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - edit { it[key] = adapter.toJson(value) } -} +suspend inline fun AppPreferencesStore.storeObjectList(key: Preferences.Key, value: List) = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + edit { it[key] = adapter.toJson(value) } + } /** Get flow of list of data [T] by string [key]. If data is not found, it returns `null` */ inline fun AppPreferencesStore.getObjectList(key: Preferences.Key): Flow?> { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - return data.map { it[key]?.let(adapter::fromJson) }.distinctUntilChanged() + return flow { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + emitAll( + data.map { + it[key]?.let(adapter::fromJson) + }.distinctUntilChanged(), + ) + } } /** Get list of data [T] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectListSync(key: Preferences.Key): List { - val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} +suspend inline fun AppPreferencesStore.getObjectListSync(key: Preferences.Key): List = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Store map with [String] key and value [V] by string [key] */ suspend inline fun AppPreferencesStore.storeObjectMap( key: Preferences.Key, value: Map, -) { +) = withContext(dispatchers.io) { val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) val adapter = moshi.adapter>(type) @@ -125,37 +141,47 @@ suspend inline fun AppPreferencesStore.storeObjectMap( } /** Get map with [String] key and value [V] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectMapSync(key: Preferences.Key): Map { - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) +suspend inline fun AppPreferencesStore.getObjectMapSync(key: Preferences.Key): Map = + withContext(dispatchers.io) { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Get flow of map with [String] key and value [V] by string [key], or empty if data is not found */ inline fun AppPreferencesStore.getObjectMap(key: Preferences.Key): Flow> { - val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) - val adapter = moshi.adapter>(type) + return flow { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) - return data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() } + emitAll( + data.map { it[key]?.let(adapter::fromJson) ?: emptyMap() }, + ) + } } /** Get set of data [T] by string [key], or empty if data is not found */ -suspend inline fun AppPreferencesStore.getObjectSetSync(key: Preferences.Key): Set { - val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) - return data.firstOrNull() - ?.get(key) - ?.let(adapter::fromJson) - .orEmpty() -} +suspend inline fun AppPreferencesStore.getObjectSetSync(key: Preferences.Key): Set = + withContext(dispatchers.io) { + val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) + data.firstOrNull() + ?.get(key) + ?.let(adapter::fromJson) + .orEmpty() + } /** Get flow of set of [T] by string [key], or empty if data is not found */ inline fun AppPreferencesStore.getObjectSet(key: Preferences.Key): Flow> { - val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) - return data.map { - it[key]?.let(adapter::fromJson) ?: emptySet() + return flow { + val adapter = moshi.adapter>(Types.newParameterizedType(Set::class.java, T::class.java)) + emitAll( + data.map { + it[key]?.let(adapter::fromJson) ?: emptySet() + }, + ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt index a049816e5c..386887a13b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SharedPreferencesKeyMigration.kt @@ -22,7 +22,9 @@ internal class SharedPreferencesKeyMigration( private val keyName: String, ) : DataMigration { - private val legacyPrefs = context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE) + private val legacyPrefs by lazy { + context.getSharedPreferences(legacyPrefsName, Context.MODE_PRIVATE) + } override suspend fun cleanUp() { val sharedPrefsEditor = legacyPrefs.edit() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt index 120bea1730..54af10bcb2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/quote/converter/QuoteConverter.kt @@ -10,13 +10,24 @@ import com.tangem.utils.extensions.orZero /** * Converter from [QuotesResponse.Quote] to [Quote.Value] * - * @property isCached flag that determines whether the quote is a cache + * @property source status source * [REDACTED_AUTHOR] */ -internal class QuoteConverter(private val isCached: Boolean) : +class QuoteConverter( + private val source: StatusSource, +) : Converter, Quote.Value> { + /** + * Secondary constructor + * + * @param isCached flag that determines whether the quote is a cache + */ + constructor(isCached: Boolean) : this( + source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL, + ) + override fun convert(value: Map.Entry): Quote.Value { val (currencyId, quote) = value @@ -24,7 +35,7 @@ internal class QuoteConverter(private val isCached: Boolean) : rawCurrencyId = CryptoCurrency.RawID(currencyId), fiatRate = quote.price.orZero(), priceChange = quote.priceChange24h.orZero().movePointLeft(2), - source = if (isCached) StatusSource.CACHE else StatusSource.ACTUAL, + source = source, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/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/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt index 49924b9fe8..1bbee64573 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/loader/AssetLoaderTest.kt @@ -7,6 +7,7 @@ import com.squareup.moshi.Types import com.squareup.moshi.adapter import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.asset.reader.AssetReader +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery import io.mockk.coVerifyOrder import io.mockk.every @@ -22,7 +23,11 @@ class AssetLoaderTest { private val assetReader = mockk() private val moshi = mockk() - private val assetLoader = AssetLoader(assetReader = assetReader, moshi = moshi) + private val assetLoader = AssetLoader( + assetReader = assetReader, + moshi = moshi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) @Test fun load() = runTest { diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt index c1beef670e..651d234c8d 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/asset/reader/AndroidAssetReaderTest.kt @@ -2,7 +2,6 @@ package com.tangem.datasource.asset.reader import android.content.res.AssetManager import com.google.common.truth.Truth -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -15,7 +14,7 @@ import java.io.IOException internal class AndroidAssetReaderTest { private val assetManager = mockk() - private val assetReader = AndroidAssetReader(assetManager, TestingCoroutineDispatcherProvider()) + private val assetReader = AndroidAssetReader(assetManager) @Test fun read_content() = runTest { diff --git a/core/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-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index b7a04e5add..7d9d38c56c 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -503,6 +503,12 @@ %d Stück %d Stücke + NFTs, die an Deine Wallet-Adresse gesendet werden, werden hier angezeigt. + Noch keine Kollektionen + NFT erhalten + NFT-Kollektionen + Einige Daten werden möglicherweise nicht geladen + Vorübergehende Ladeprobleme %1$d NFTs in der %2$d Sammlung Tippe hier, um das erste NFT zu erhalten NFT-Sammlungen @@ -522,7 +528,7 @@ Bitte wiederhole den Vorgang. Die Karte oder Ring wird auf Werkseinstellungen zurückgesetzt. Aktivierungsfehler Token hinzufügen - Du hast einee Backup-Karte oder einen Backup-Ring hinzugefügt. Wenn der Backup-Prozess abgeschlossen ist, kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du noch eine Karte oder einen Ring hast, fügen diese(n) zum Backup hinzu. Möchtest Du den Backup-Prozess fortsetzen? + Du hast eine Backup-Karte oder einen Backup-Ring hinzugefügt. Nach Abschluss des Backup-Vorgangs kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du eine weitere Karte oder einen weiteren Ring hast, fügen diesen dem Backup jetzt hinzu. Möchtest Du den Backup-Vorgang fortsetzen? Der Sicherungsvorgang ist teilweise abgeschlossen. Du kannst ihn jetzt nicht beenden. Die Passphrase ist eine fortschrittliche Sicherheitsfunktion, die von Krypto-Wallets verwendet wird. Sie fügt ein zusätzliches Wort oder eine Phrase deiner Wahl zu der bereits bestehenden Wiederherstellungsphrase hinzu, um einen brandneuen Satz von Adressen zu erzeugen. Hinzufügen einer Sicherungskarte oder Ring @@ -1165,6 +1171,7 @@ Der Kontostand ist möglicherweise veraltet. Aktualisiere bittre die Seite. Verbindungen Alle trennen + Text über die Trennung aller dApps Alle dApps trennen Neue Verbindung Verbinde Deine Wallet mit einer anderen dApp diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 01f05ff01c..47b464f247 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -140,6 +140,7 @@ Marché Lent Vitesse et frais + Terminer Synchroniser les adresses Aller au fournisseur Aller au jeton @@ -159,6 +160,7 @@ Aucune adresse Maintenant OK + Ouvrir dans le navigateur Carte principale Bague principale Passphrase @@ -182,6 +184,7 @@ Envoyer Le serveur n\'est pas disponible, veuillez réessayer plus tard Partager + Partager le lien Signez Signez et envoyez Stake @@ -496,6 +499,16 @@ Tirez vers le haut ou appuyez sur la barre de recherche pour ajouter des jetons directement depuis le marché Ajouter des jetons NFC n\'est pas disponible sur votre appareil + Les NFT envoyés à l\'adresse de votre portefeuille s\'afficheront ici. + Aucune collection pour le moment + Recevoir des NFT + Collections NFT + Certaines données peuvent ne pas se charger + Problèmes de chargement temporaires + %1$d NFT dans la collection %2$d + Appuyez ici pour recevoir le premier NFT + Collections NFT + Impossible de charger les données Vous devez définir un seul code d\'accès pour protéger tous vos appareils. Protéger Vous pourrez définir un code d\'accès individuel sur chaque carte plus tard @@ -762,6 +775,8 @@ y compris des frais de réseau de %1$s La transaction a été signée avec succès et envoyée au nœud de blockchain. Le solde du portefeuille sera mis à jour après un certain temps %1$s est un actif du réseau Tron. Pour calculer les frais et effectuer une transaction, déposez du Tron (TRX) sur votre compte. + Une balise de destination (mémo) est requise pour terminer cette transaction pour l\'adresse spécifiée. + Étiquette de destination requise Transaction envoyée Scannez la carte/ bague que vous souhaitez configurer. Oublier le portefeuille @@ -783,6 +798,7 @@ %s profit estimatif Cote du marché Métriques + Selon les règles du réseau %1$s, les réclamations sont possibles à partir de %2$s. Les montants ci-dessous seront crédités sur votre compte lors du déblocage. Minimum requis Aucune récompense à réclamer Réclamation de récompense @@ -820,11 +836,16 @@ Solde de staking faible Un minimum de %1$s %2$s est requis pour le re-staking. Veuillez recharger votre solde. Pas assez de %s + Solde insuffisant pour le staking + Un minimum de 3 ADA est requis pour le re-staking. Veuillez recharger votre solde. + ADA insuffisants + Le montant minimum requis pour le staking doit être supérieur à 5 ADA. Veuillez recharger votre solde pour commencer à staking. L\'option de staking n\'est actuellement pas disponible en raison des conditions du réseau. Veuillez réessayer plus tard. Le staking dans le réseau %1$s avec un nouveau validateur transférera automatiquement tous les fonds précédemment stakés vers ce validateur Réinvestissez vos récompenses gagnées dans le montant que vous avez staké, augmentant ainsi vos gains potentiels. L\'option de restaker vous permet de déplacer vos fonds d\'un validateur à un autre sans avoir besoin de les déstaker. Vous êtes sur le point de staker l\'intégralité de votre solde. Nous vous recommandons de laisser un petit montant pour couvrir les frais de réseau pour unstaking ou la réclamation des récompenses. + Pour commencer à staker TON, effectuez d\'abord une transaction sortante de n\'importe quel montant — cela activera votre portefeuille. Débloquez votre argent pour le retirer du processus de staking. Le déverrouillage prend %s. Vos fonds seront disponibles à l\'utilisation après la période de déblocage de 21 jours. La récompense sera retirée en même temps que vos fonds de déblocage. Vos fonds seront disponibles pour utilisation après la période de désengagement %s. @@ -853,6 +874,7 @@ Récompenses Stake verrouillé Staker plus + Lorsque vous stakez %1$s, la totalité de votre solde %2$s est stakeée. Tout dépôt supplémentaire de %2$s sur votre portefeuille Tangem sera également staké automatiquement. Montant staké Vous stakez %1$s et recevrez %2$s Appuyez pour déverrouiller @@ -887,6 +909,8 @@ Découvrez Tangem Échangez, achetez des NFT, faites des prêts et des dépôts dans plus de 100 services décentralisés différents Compatible avec Web 3.0 + Une transaction entrante d\'au moins de %1$s est requise pour continuer + Fonds insuffisants Le réseau facturera des frais d\'approbation de jeton pour vérifier que vous autorisez l\'utilisation de votre jeton pour l\'échange. Échangez plus de jetons à de meilleurs taux directement dans votre portefeuille. Nouveau fournisseur d\'échange disponible ! @@ -926,6 +950,7 @@ Vous n\'avez pas de fonds à envoyer. Renflouez votre compte pour pouvoir envoyer des fonds à partir de celui-ci. Die Daten wurden noch nicht geladen. Dies kann einige Sekunden dauern. Bitte versuchen Sie es später noch einmal. Le service d\'échange %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options. + Le solde affiché peut être obsolète en raison de la mise en cache. La vente de fonds sera disponible une fois que la ou les transactions en attente dans le réseau %s seront terminées L\'envoi de fonds sera disponible une fois la ou les transactions en attente dans le réseau %s terminées. L\'achat de %s n\'est pas pris en charge par les fournisseurs actuels mais nous travaillons à ajouter plus d\'options. @@ -972,6 +997,7 @@ Tangem Twin Cette action est irréversible. Vous n\'aurez plus accès à l\'ancien portefeuille. Appuyez sur la carte jumelle avec le numéro %s et ne la retirez pas jusqu\'à la fin de l\'opération + Nous avons rencontré une erreur. Code d\'erreur : %s. Veuillez contacter notre équipe de support. Utilisez %s ou scannez une carte/bague pour avoir accès à votre portefeuille Échec de la connexion : Cette dApp utilise Wallet Connect version1.0, qui n\'est pas prise en charge. Veuillez vous assurer que la dApp prend en charge Wallet Connect version2.0 pour réussir la connexion. Restez à jour avec les dernières fonctionnalités et actualités @@ -986,6 +1012,24 @@ Renommer le portefeuille Tout déverrouiller Tout déverrouiller avec %s + + disponible pour %d jour + disponible pour %d jours + + Soldes et Limites + Êtes-vous sûr de vouloir quitter ? Vous pourrez reprendre plus tard là où vous vous étiez arrêté. + Cela ne prendra pas longtemps. Nous configurons votre compte. + Cela ne prendra pas longtemps. Nous terminons l\'activation. + Tout est en cours de préparation ! + Code PIN invalide : évitez les séquences ou les répétitions + Accéder le site Web + Continuons la configuration de votre compte. + Content de vous revoir ! + Suivez les étapes pour configurer votre compte. + Bienvenue ! + Déverrouiller + Scannez votre carte pour déverrouiller l\'accès + Déverrouillage nécessaire La blockchain n\'est pas accessible. Réessayez plus tard Scanner la carte ou la bague Ce portefeuille a déjà été activé auparavant.\nSi cela n\'a pas été fait par vous, veuillez contacter le support.\nTangem ne vend jamais de portefeuilles avec le code d\'accès pré-généré. @@ -1116,6 +1160,13 @@ Il s\'agit d\'une carte Testnet. Elle ne peut pas traiter les transactions et ne doit être utilisée qu\'à des fins de test et de développement. À des fins de test uniquement Le solde peut être obsolète. Rafraîchissez la page. + Connexions + Déconnecter tout + Texte sur la déconnexion de toutes les dApps + Déconnecter toutes les dApps + Nouvelle connexion + Connectez votre portefeuille à différentes dApps + Aucune séance Ignorer Vous avez une sauvegarde interrompue. Voulez-vous la reprendre ? Oui, reprendre diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 9b67c45675..b59492d1ed 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -109,6 +109,8 @@ 報酬を受け取る 閉じる 確認 + Tangemサポートへ問い合わせる + Visaサポートへ問い合わせる 続ける コピー アドレスをコピー @@ -153,6 +155,7 @@ ネットワーク手数料 送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。 + NFT いいえ アドレスがありません @@ -175,6 +178,7 @@ 検索 トークンを検索 + すべて見る シードフレーズ アクションを選択 売る @@ -502,6 +506,20 @@ NFTコレクション 一部のデータが読み込まれない場合があります 一時的な読み込みの問題 + 基本情報 + チェーン + コントラクトアドレス + 最終販売価格 + レアリティ・ラベル + レアリティ・ランク + トークンアドレス + トークンID + トークン標準 + 特徴 + 結果がありません。別のリクエストをお試しください。 + ネットワークを選択 + 私のウォレットへ + NFTを受け取る %1$dコレクションの%2$dNFT ここをタップして最初のNFTを受け取ります NFTコレクション @@ -635,6 +653,7 @@ カメラへのアクセスが拒否されました メモ不要 %3$sネットワーク上の%1$s ( %2$s ) + %2$sネットワーク上の%1$s 他の暗号資産を送信すると、取り返しのつかない損失が発生します。 このアドレスには%sのみを送金してください。他のトークンを送信すると、取り返しのつかない損失が発生します。 %2$sネットワークの%1$sのみを送信してください @@ -1015,12 +1034,14 @@ 長くはかかりません。アカウントを設定しています。 長くはかかりません。アクティベーションを完了しています。 準備完了です! + PINの認証に失敗しました。もう一度お試しいただくか、別のコードを使用してください。 無効な暗証番号:連続や繰り返しを避けてください ウェブサイトに移動 アカウントの設定を続けましょう。 お帰りなさい! 手順に従ってアカウントを設定してください。 ようこそ! + この取引に異議を唱える ロック解除 カードをスキャンしてアクセスロックを解除する ロック解除が必要 diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index de090f81f6..3ea8b2da0c 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1020,6 +1020,7 @@ Переименование кошелька Разблокировать все Разблокировать все с %s + ПИН не принят. Попробуйте ещё раз или введите другой код. Блокчейн недоступен. Попробуйте позже. Отсканируйте карту или кольцо Этот кошелек уже был активирован ранее.\nЕсли это сделали не вы, свяжитесь со службой поддержки.\nTangem никогда не продает кошелек вместе с предустановленным кодом доступа. diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index c64eaaec68..440dac2a29 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -110,6 +110,8 @@ Claim rewards Close Confirm + Contact Tangem Support + Contact Visa Support Continue Copy Copy address @@ -156,6 +158,7 @@ Network fee Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level Next + NFT No No address Now @@ -178,6 +181,7 @@ Search Search tokens sec + See all Seed phrase Select action Sell @@ -510,6 +514,21 @@ NFT collections Some data may not load Temporary loading problems + Base information + Chain + Contract Address + Last sale price + Rarity label + Rarity rank + Token Address + Token ID + Token Standard + Traits + No results. Please try another request. + Choose network + To My wallet + Receive NFT + Traits %1$d NFTs in %2$d collection Tap here to receive first NFT NFT collections @@ -645,6 +664,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 @@ -1050,6 +1070,7 @@ Set up a 4-digit code. 
It will be used for payments. PIN code Create PIN Code + PIN was not accepted. Try again or use a different code. Invalid PIN: avoid sequences or repeats You\'re good to go! Prepare the Tangem card and tap to approve @@ -1081,6 +1102,7 @@ Transaction request Transaction status Type + Dispute this transaction Unlock Scan your card to unlock access Needed unlock 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/artwork/ArtworkUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/artwork/ArtworkUM.kt new file mode 100644 index 0000000000..5c1f568b02 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/artwork/ArtworkUM.kt @@ -0,0 +1,17 @@ +package com.tangem.core.ui.components.artwork + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Immutable +data class ArtworkUM( + val verifiedArtwork: ImmutableList? = null, + val defaultUrl: String, +) { + + constructor(bytes: ByteArray?, defaultUrl: String) : this( + verifiedArtwork = bytes?.toList()?.toImmutableList(), + defaultUrl = defaultUrl, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt new file mode 100644 index 0000000000..4f071c2b9d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/ReadMoreText.kt @@ -0,0 +1,485 @@ +package com.tangem.core.ui.components.atoms.text + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp + +/** + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +private const val READ_MORE_TAG = "read_more" +private const val READ_LESS_TAG = "read_less" + +/** + * Basic element that displays text with read more. + * + * @param text The text to be displayed. + * @param expanded whether this text is expanded or collapsed. + * @param modifier [Modifier] to apply to this layout node. + * @param onExpandRequested called when this text is clicked. If `null`, then this text will not be + * interactable, unless something else handles its input events and updates its state. + * @param contentPadding a padding around the text. + * @param style Style configuration for the text such as color, font, line height etc. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw selection around the text. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [readMoreOverflow] and TextAlign may have unexpected effects. + * @param readMoreText The read more text to be displayed in the collapsed state. + * @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if + * necessary. If the text exceeds the given number of lines, it will be truncated according to + * [readMoreOverflow]. If it is not null, then it must be greater than zero. + * @param readMoreOverflow How visual overflow should be handled in the collapsed state. + * @param readMoreStyle Style configuration for the read more text such as color, font, line height + * etc. + * @param readLessText The read less text to be displayed in the expanded state. + * @param readLessStyle Style configuration for the read less text such as color, font, line height + * etc. + * @param toggleArea A clickable area of text to toggle. + */ +@Composable +fun ReadMoreText( + text: String, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + ReadMoreTextInternal( + text = AnnotatedString(text), + expanded = expanded, + modifier = modifier, + onExpandRequested = onExpandRequested, + contentPadding = contentPadding, + style = style, + onTextLayout = onTextLayout, + softWrap = softWrap, + readMoreText = readMoreText, + readMoreMaxLines = readMoreMaxLines, + readMoreOverflow = readMoreOverflow, + readMoreStyle = readMoreStyle, + readLessText = readLessText, + readLessStyle = readLessStyle, + toggleArea = toggleArea, + ) +} + +/** + * Basic element that displays text with read more. + * + * @param text The text to be displayed. + * @param expanded whether this text is expanded or collapsed. + * @param modifier [Modifier] to apply to this layout node. + * @param onExpandRequested called when this text is clicked. If `null`, then this text will not be + * interactable, unless something else handles its input events and updates its state. + * @param contentPadding a padding around the text. + * @param style Style configuration for the text such as color, font, line height etc. + * @param onTextLayout Callback that is executed when a new text layout is calculated. A + * [TextLayoutResult] object that callback provides contains paragraph information, size of the + * text, baselines and other details. The callback can be used to add additional decoration or + * functionality to the text. For example, to draw selection around the text. + * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the + * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false, + * [readMoreOverflow] and TextAlign may have unexpected effects. + * @param readMoreText The read more text to be displayed in the collapsed state. + * @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if + * necessary. If the text exceeds the given number of lines, it will be truncated according to + * [readMoreOverflow]. If it is not null, then it must be greater than zero. + * @param readMoreOverflow How visual overflow should be handled in the collapsed state. + * @param readMoreStyle Style configuration for the read more text such as color, font, line height + * etc. + * @param readLessText The read less text to be displayed in the expanded state. + * @param readLessStyle Style configuration for the read less text such as color, font, line height + * etc. + * @param toggleArea A clickable area of text to toggle. + */ +@Composable +fun ReadMoreText( + text: AnnotatedString, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + ReadMoreTextInternal( + text = text, + expanded = expanded, + modifier = modifier, + onExpandRequested = onExpandRequested, + contentPadding = contentPadding, + style = style, + onTextLayout = onTextLayout, + softWrap = softWrap, + readMoreText = readMoreText, + readMoreMaxLines = readMoreMaxLines, + readMoreOverflow = readMoreOverflow, + readMoreStyle = readMoreStyle, + readLessText = readLessText, + readLessStyle = readLessStyle, + toggleArea = toggleArea, + ) +} + +@Suppress("LongMethod", "LongParameterList") +@Composable +private fun ReadMoreTextInternal( + text: AnnotatedString, + expanded: Boolean, + modifier: Modifier = Modifier, + onExpandRequested: ((Boolean) -> Unit)? = null, + contentPadding: PaddingValues = PaddingValues(0.dp), + style: TextStyle = TextStyle.Default, + onTextLayout: (TextLayoutResult) -> Unit = {}, + softWrap: Boolean = true, + readMoreText: String = "", + readMoreMaxLines: Int = 2, + readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis, + readMoreStyle: SpanStyle = style.toSpanStyle(), + readLessText: String = "", + readLessStyle: SpanStyle = readMoreStyle, + toggleArea: ToggleArea = ToggleArea.All, +) { + require(readMoreMaxLines > 0) { "readMoreMaxLines should be greater than 0" } + + val overflowText: String = remember(readMoreOverflow) { + buildString { + when (readMoreOverflow) { + ReadMoreTextOverflow.Clip -> { + } + ReadMoreTextOverflow.Ellipsis -> { + append(Typography.ellipsis) + } + } + if (readMoreText.isNotEmpty()) { + append(Typography.nbsp) + } + } + } + val readMoreTextWithStyle: AnnotatedString = remember(readMoreText, readMoreStyle) { + buildAnnotatedString { + if (readMoreText.isNotEmpty()) { + withStyle(readMoreStyle) { + append(readMoreText.replace(' ', Typography.nbsp)) + } + } + } + } + val readLessTextWithStyle: AnnotatedString = remember(readLessText, readLessStyle) { + buildAnnotatedString { + if (readLessText.isNotEmpty()) { + withStyle(readLessStyle) { + append(readLessText) + } + } + } + } + + val textMeasurer = rememberTextMeasurer() + val state = remember { ReadMoreState() } + + val currentText = buildAnnotatedString { + if (expanded) { + append(text) + if (readLessTextWithStyle.isNotEmpty()) { + append(' ') + if (toggleArea == ToggleArea.More) { + withLink( + LinkAnnotation.Clickable(tag = READ_LESS_TAG) { + onExpandRequested?.invoke(false) + }, + ) { + append(readLessTextWithStyle) + } + } else { + append(readLessTextWithStyle) + } + } + } else { + val collapsedText = state.collapsedText + if (collapsedText.isNotEmpty()) { + append(collapsedText) + append(overflowText) + + if (toggleArea == ToggleArea.More) { + withLink( + LinkAnnotation.Clickable(tag = READ_MORE_TAG) { + onExpandRequested?.invoke(true) + }, + ) { + append(readMoreTextWithStyle) + } + } else { + append(readMoreTextWithStyle) + } + } else { + append(text) + } + } + } + val toggleableModifier = if (onExpandRequested != null && toggleArea == ToggleArea.All) { + Modifier.clickable( + enabled = state.isCollapsible, + onClick = { onExpandRequested(!expanded) }, + ) + } else { + Modifier + } + BoxWithConstraints( + modifier = modifier + .then(toggleableModifier) + .padding(contentPadding), + ) { + BasicText( + text = currentText, + modifier = Modifier, + style = style, + onTextLayout = onTextLayout, + overflow = TextOverflow.Ellipsis, + softWrap = softWrap, + maxLines = if (expanded) Int.MAX_VALUE else readMoreMaxLines, + ) + + val constraints = Constraints(maxWidth = constraints.maxWidth) + LaunchedEffect( + textMeasurer, + constraints, + overflowText, + readMoreTextWithStyle, + style, + readMoreStyle, + text, + readMoreMaxLines, + softWrap, + ) { + state.applyCollapsedText( + textMeasurer = textMeasurer, + constraints = constraints, + overflowText = overflowText, + readMoreTextWithStyle = readMoreTextWithStyle, + style = style, + readMoreStyle = readMoreStyle, + text = text, + readMoreMaxLines = readMoreMaxLines, + softWrap = softWrap, + ) + } + } +} + +@Stable +private class ReadMoreState { + private var _collapsedText: AnnotatedString by mutableStateOf(AnnotatedString("")) + + var collapsedText: AnnotatedString + get() = _collapsedText + internal set(value) { + if (value != _collapsedText) { + _collapsedText = value + } + } + + val isCollapsible: Boolean + get() = collapsedText.isNotEmpty() + + @Suppress("LongParameterList") + fun applyCollapsedText( + textMeasurer: TextMeasurer, + constraints: Constraints, + overflowText: String, + readMoreTextWithStyle: AnnotatedString, + style: TextStyle, + readMoreStyle: SpanStyle, + text: AnnotatedString, + readMoreMaxLines: Int, + softWrap: Boolean, + ) { + val overflowTextWidth = if (overflowText.isNotEmpty()) { + textMeasurer.measure( + text = overflowText, + style = style, + ).size.width + } else { + 0 + } + val readMoreTextWidth = if (readMoreTextWithStyle.isNotEmpty()) { + textMeasurer.measure( + text = readMoreTextWithStyle, + style = style.merge(readMoreStyle), + ).size.width + } else { + 0 + } + val textLayout = textMeasurer.measure( + text = text, + style = style, + maxLines = readMoreMaxLines, + overflow = TextOverflow.Clip, + softWrap = softWrap, + constraints = constraints, + ) + + val clipTextCount = textLayout.getLineEnd(lineIndex = textLayout.lineCount - 1) + val isLineClipped = text.count() > clipTextCount + if (isLineClipped) { + val countUntilMaxLine = + textLayout.getLineEnd(readMoreMaxLines - 1, visibleEnd = true) + + val decorationWidth = overflowTextWidth + readMoreTextWidth + val replaceCount = text + .substringOf(textLayout, line = readMoreMaxLines) + .calculateReplaceCountToBeSingleLineWith( + maximumTextWidth = constraints.maxWidth - decorationWidth, + measureTextWidth = { subText -> + textMeasurer.measure( + text = subText, + style = style, + softWrap = softWrap, + ).size.width + }, + ) + collapsedText = text.subSequence(0, countUntilMaxLine - replaceCount) + } else { + collapsedText = AnnotatedString("") + } + } + + private fun AnnotatedString.substringOf(layout: TextLayoutResult, line: Int): AnnotatedString { + val lastLineStartIndex = layout.getLineStart(line - 1) + val lastLineEndIndex = layout.getLineEnd(line - 1, visibleEnd = true) + return subSequence(lastLineStartIndex, lastLineEndIndex) + } + + private inline fun AnnotatedString.calculateReplaceCountToBeSingleLineWith( + maximumTextWidth: Int, + measureTextWidth: (subText: AnnotatedString) -> Int, + ): Int { + var replacedTextWidth: Int + var replacedCount = -1 + do { + replacedCount++ + replacedTextWidth = measureTextWidth( + subSequence(0, this.length - replacedCount), + ) + } while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth) + + val lastVisibleChar: Char? = this.getOrNull(this.length - replacedCount - 1) + val firstOverflowChar: Char? = this.getOrNull(this.length - replacedCount) + if (lastVisibleChar?.isSurrogate() == true && firstOverflowChar?.isHighSurrogate() == false) { + val subText = subSequence(0, this.length - replacedCount) + if (subText.isNotEmpty()) { + return length - subText.indexOfLast { it.isHighSurrogate() } + } + } + return replacedCount + } +} + +@JvmInline +value class ToggleArea private constructor(internal val value: Int) { + + override fun toString(): String { + return when (this) { + All -> "All" + More -> "More" + else -> "Invalid" + } + } + + companion object { + /** + * All area of the text is clickable to toggle. + */ + @Stable + val All: ToggleArea = ToggleArea(1) + + /** + * 'More' and 'Less' area of the text is clickable to toggle. + */ + @Stable + val More: ToggleArea = ToggleArea(2) + } +} + +@JvmInline +value class ReadMoreTextOverflow private constructor(internal val value: Int) { + + override fun toString(): String { + return when (this) { + Clip -> "Clip" + Ellipsis -> "Ellipsis" + else -> "Invalid" + } + } + + companion object { + /** + * Clip the overflowing text to fix its container. + */ + @Stable + val Clip: ReadMoreTextOverflow = ReadMoreTextOverflow(1) + + /** + * Use an ellipsis to indicate that the text has overflowed. + */ + @Stable + val Ellipsis: ReadMoreTextOverflow = ReadMoreTextOverflow(2) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/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 63% 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..f7020ce9e4 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 @@ -9,22 +9,24 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import com.tangem.core.ui.res.TangemTheme @Composable -internal fun TopBadge( +fun CurrencyIconTopBadge( @DrawableRes iconResId: Int, alpha: Float, colorFilter: ColorFilter?, modifier: Modifier = Modifier, + background: Color = TangemTheme.colors.background.primary, ) { Box( modifier = modifier .size(TangemTheme.dimens.size18) .background( - color = TangemTheme.colors.background.primary, + color = background, shape = CircleShape, ), ) { @@ -38,26 +40,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/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt index c1855597a2..7fc333d67d 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/converters/CardInfoConverter.kt @@ -1,6 +1,7 @@ package com.tangem.data.feedback.converters import com.tangem.domain.common.TapWorkarounds.isStart2Coin +import com.tangem.domain.common.TapWorkarounds.isVisa import com.tangem.domain.common.util.getBackupCardsCount import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.models.scan.CardDTO @@ -29,6 +30,7 @@ internal object CardInfoConverter : Converter { }, isImported = value.card.wallets.any(CardDTO.Wallet::isImported), isStart2Coin = value.card.isStart2Coin, + isVisa = value.card.isVisa, ) } } diff --git a/data/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..94d819b827 --- /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, + 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/quotes/.gitignore b/data/quotes/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/quotes/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/quotes/build.gradle.kts b/data/quotes/build.gradle.kts new file mode 100644 index 0000000000..9005f39528 --- /dev/null +++ b/data/quotes/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.data.quotes" +} + +dependencies { + implementation(projects.core.datasource) + implementation(projects.core.utils) + + implementation(projects.data.common) + implementation(projects.data.tokens) + + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.models) + implementation(projects.domain.tokens.models) + implementation(projects.domain.quotes) + implementation(projects.domain.wallets.models) + + implementation(deps.androidx.datastore) + implementation(deps.timber) + + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt new file mode 100644 index 0000000000..5fc337e08c --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcher.kt @@ -0,0 +1,62 @@ +package com.tangem.data.quotes.multi + +import arrow.core.Either +import com.tangem.data.common.api.safeApiCallWithTimeout +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.tokens.model.CryptoCurrency +import timber.log.Timber + +/** + * Default implementation of [MultiQuoteFetcher] + * + * @property tangemTechApi tangemTech api + * @property appCurrencyResponseStore app currency response store + * @property quotesStore quotes store + * +[REDACTED_AUTHOR] + */ +internal class DefaultMultiQuoteFetcher( + private val tangemTechApi: TangemTechApi, + private val appCurrencyResponseStore: AppCurrencyResponseStore, + private val quotesStore: QuotesStoreV2, +) : MultiQuoteFetcher { + + private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter() + + override suspend fun invoke(params: MultiQuoteFetcher.Params): Either = Either.catch { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies( + currenciesIds = params.currenciesIds.mapTo( + destination = hashSetOf(), + transform = CryptoCurrency.RawID::value, + ), + ) + + val appCurrency = appCurrencyResponseStore.getSyncOrNull() + ?: error(message = "Unable to get AppCurrency for updating quotes") + + safeApiCallWithTimeout( + call = { + val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",") + val response = tangemTechApi.getQuotes(currencyId = appCurrency.id, coinIds = coinIds).bind() + + val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies( + response = response, + filteredIds = replacementIdsResult.idsFiltered, + ) + + quotesStore.storeActual(values = updatedResponse.quotes) + }, + onError = { error -> throw error }, + ) + } + .onLeft { + Timber.e(it) + quotesStore.storeError(currenciesIds = params.currenciesIds) + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt new file mode 100644 index 0000000000..d8450243b6 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducer.kt @@ -0,0 +1,40 @@ +package com.tangem.data.quotes.single + +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull + +/** + * Default implementation of [SingleQuoteProducer] + * + * @property params params + * @property quotesStore quotes store + */ +internal class DefaultSingleQuoteProducer @AssistedInject constructor( + @Assisted val params: SingleQuoteProducer.Params, + private val quotesStore: QuotesStoreV2, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleQuoteProducer { + + override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + + override fun produce(): Flow { + return quotesStore.get() + .mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @AssistedFactory + interface Factory : SingleQuoteProducer.Factory { + override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt new file mode 100644 index 0000000000..2ae06d88e6 --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/DefaultQuotesStoreV2.kt @@ -0,0 +1,89 @@ +package com.tangem.data.quotes.store + +import androidx.datastore.core.DataStore +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.datasource.local.quote.converter.QuoteConverter +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.addOrReplace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +internal typealias CurrencyIdWithQuote = Map + +/** + * Default implementation of [QuotesStoreV2] + * + * @property runtimeStore runtime store + * @property persistenceDataStore persistence store + * @param dispatchers dispatchers + */ +internal class DefaultQuotesStoreV2( + private val runtimeStore: RuntimeSharedStore>, + private val persistenceDataStore: DataStore, + dispatchers: CoroutineDispatcherProvider, +) : QuotesStoreV2 { + + private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) + + init { + scope.launch { + val cachedStatuses = persistenceDataStore.data.firstOrNull() + + if (cachedStatuses.isNullOrEmpty()) return@launch + + runtimeStore.store( + value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries), + ) + } + } + + override fun get(): Flow> = runtimeStore.get() + + override suspend fun refresh(currenciesIds: Set) { + updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE) + } + + override suspend fun storeActual(values: Map) { + coroutineScope { + launch { + val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries) + storeInRuntimeStore(values = quotes) + } + launch { storeInPersistenceStore(values = values) } + } + } + + override suspend fun storeError(currenciesIds: Set) { + updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.ONLY_CACHE) + } + + private suspend fun updateStatusSourceInRuntime(currenciesIds: Set, source: StatusSource) { + runtimeStore.update(default = emptySet()) { stored -> + val updatedQuotes = currenciesIds.mapTo(hashSetOf()) { id -> + val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: Quote.Empty(id) + + quote.copySealed(source = source) + } + + stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId } + } + } + + private suspend fun storeInRuntimeStore(values: Set) { + runtimeStore.update(default = emptySet()) { saved -> + saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId } + } + } + + private suspend fun storeInPersistenceStore(values: Map) { + persistenceDataStore.updateData { storedQuotes -> storedQuotes + values } + } +} \ No newline at end of file diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt new file mode 100644 index 0000000000..b530a6559c --- /dev/null +++ b/data/quotes/src/main/java/com/tangem/data/quotes/store/QuotesStoreV2.kt @@ -0,0 +1,22 @@ +package com.tangem.data.quotes.store + +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import kotlinx.coroutines.flow.Flow + +/** Store of [Quote]'es set */ +internal interface QuotesStoreV2 { + + /** Get flow of quotes */ + fun get(): Flow> + + /** Refresh status of [currenciesIds] */ + suspend fun refresh(currenciesIds: Set) + + /** Store actual map of currency ids and quotes [values] */ + suspend fun storeActual(values: Map) + + /** Store error for [currenciesIds] */ + suspend fun storeError(currenciesIds: Set) +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt new file mode 100644 index 0000000000..b35a5c6f06 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteFetcherTest.kt @@ -0,0 +1,143 @@ +package com.tangem.data.quotes.multi + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.datasource.appcurrency.AppCurrencyResponseStore +import com.tangem.domain.quotes.multi.MultiQuoteFetcher +import com.tangem.domain.tokens.model.CryptoCurrency +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiQuoteFetcherTest { + + private val tangemTechApi = mockk(relaxed = true) + private val appCurrencyResponseStore = mockk(relaxed = true) + private val quotesStore = mockk(relaxed = true) + + private val fetcher = DefaultMultiQuoteFetcher( + tangemTechApi = tangemTechApi, + appCurrencyResponseStore = appCurrencyResponseStore, + quotesStore = quotesStore, + ) + + @Test + fun `fetch quotes successfully`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val coinIds = "BTC,ETH" + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + } returns ApiResponse.Success(successResponse) + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + appCurrencyResponseStore.getSyncOrNull() + + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + + quotesStore.storeActual(values = successResponse.quotes) + } + + coVerify(inverse = true) { + quotesStore.storeError(currenciesIds = any()) + } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch quotes failure because api request failed`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency + + val coinIds = "BTC,ETH" + + @Suppress("UNCHECKED_CAST") + val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse + coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + + appCurrencyResponseStore.getSyncOrNull() + + tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) + + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + } + + @Test + fun `fetch quotes failure because app currency not found`() = runTest { + val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds) + + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null + + val actual = fetcher(params) + + coVerifyOrder { + quotesStore.refresh(currenciesIds = params.currenciesIds) + appCurrencyResponseStore.getSyncOrNull() + quotesStore.storeError(currenciesIds = params.currenciesIds) + } + + coVerify(inverse = true) { + tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesStore.storeActual(values = any()) + } + + Truth.assertThat(actual.isLeft()).isTrue() + } + + private companion object { + + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + val usdAppCurrency = CurrenciesResponse.Currency( + id = "USD".lowercase(), + code = "USD", + name = "US Dollar", + unit = "$", + type = "fiat", + rateBTC = "", + ) + + val successResponse = QuotesResponse( + quotes = mapOf( + "BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), + "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN), + ), + ) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt new file mode 100644 index 0000000000..1b56374bae --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteProducerTest.kt @@ -0,0 +1,177 @@ +package com.tangem.data.quotes.single + +import com.google.common.truth.Truth +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.data.quotes.store.QuotesStoreV2 +import com.tangem.domain.models.StatusSource +import com.tangem.domain.quotes.single.SingleQuoteProducer +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultSingleQuoteProducerTest { + + private val params = SingleQuoteProducer.Params( + rawCurrencyId = CryptoCurrency.RawID(value = "BTC"), + ) + + private val quotesStore = mockk() + + private val producer = DefaultSingleQuoteProducer( + params = params, + quotesStore = quotesStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test that flow is mapped for network from params`() = runTest { + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + val storeQuote = flowOf( + setOf( + status, + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produce() + + verify { quotesStore.get() } + + val values = 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 quote is updated`() = runTest { + val storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + // first emit + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + storeQuote.emit(value = setOf(status)) + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(status)) + + // second emit + val updatedStatus = Quote.Value( + rawCurrencyId = params.rawCurrencyId, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + storeQuote.emit(value = setOf(updatedStatus)) + + val values2 = 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 storeQuote = MutableSharedFlow>(replay = 2, extraBufferCapacity = 1) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + // first emit + val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + storeQuote.emit(value = setOf(status)) + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + Truth.assertThat(values1).isEqualTo(listOf(status)) + + // second emit + storeQuote.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 = Quote.Value( + rawCurrencyId = params.rawCurrencyId, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ) + + val innerFlow = MutableStateFlow(value = false) + val storeQuote = flow { + if (innerFlow.value) { + emit(setOf(status)) + } else { + throw exception + } + } + .buffer(capacity = 5) + + every { quotesStore.get() } returns storeQuote + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + val values1 = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values1.size).isEqualTo(1) + val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId) + Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus)) + + innerFlow.emit(value = true) + + val values2 = 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 storeFlow = flowOf( + setOf( + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + every { quotesStore.get() } returns storeFlow + + val actual = producer.produceWithFallback() + + verify { quotesStore.get() } + + val values = backgroundScope.getEmittedValues(testScheduler = testScheduler, actual = actual) + + Truth.assertThat(values.size).isEqualTo(0) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt new file mode 100644 index 0000000000..ede1171964 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreGetMethodTest.kt @@ -0,0 +1,63 @@ +package com.tangem.data.quotes.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.common.test.utils.getEmittedValues +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreGetMethodTest { + + private val runtimeStore = RuntimeSharedStore>() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `test get if runtime store is empty`() = runTest { + val actual = store.get() + + val values = backgroundScope.getEmittedValues(testScheduler, actual) + + Truth.assertThat(values).isEqualTo(emptyList>()) + } + + @Test + fun `test get if runtime store contains empty set`() = runTest { + runtimeStore.store(value = emptySet()) + + val actual = store.get() + + val values = backgroundScope.getEmittedValues(testScheduler, actual) + + Truth.assertThat(values).isEqualTo(listOf(emptySet())) + } + + @Test + fun `test get if runtime store is not empty`() = runTest { + val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain())) + + val actual = store.get() + + val values = backgroundScope.getEmittedValues(testScheduler, actual) + + Truth.assertThat(values.size).isEqualTo(1) + Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain()))) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt new file mode 100644 index 0000000000..2452a66004 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreInitializationTest.kt @@ -0,0 +1,82 @@ +package com.tangem.data.quotes.store + +import androidx.datastore.core.DataStore +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreInitializationTest { + + @Test + fun `test initialization if cache store is empty`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore: DataStore = mockk() + + every { persistenceStore.data } returns emptyFlow() + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) + } + + @Test + fun `test initialization if cache store contains empty map`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null) + } + + @Test + fun `test initialization if cache store is not empty`() = runTest { + val runtimeStore = RuntimeSharedStore>() + val persistenceStore = MockStateDataStore(default = emptyMap()) + + val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + persistenceStore.updateData { + it.toMutableMap().apply { + this += btcQuote + this += ethQuote + } + } + + DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + val expected = setOf( + btcQuote.toDomain(source = StatusSource.CACHE), + ethQuote.toDomain(source = StatusSource.CACHE), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt new file mode 100644 index 0000000000..407b640fd9 --- /dev/null +++ b/data/quotes/src/test/java/com/tangem/data/quotes/store/QuotesStoreUpdateMethodsTest.kt @@ -0,0 +1,131 @@ +package com.tangem.data.quotes.store + +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.data.quote.toDomain +import com.tangem.common.test.datastore.MockStateDataStore +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class QuotesStoreUpdateMethodsTest { + + private val runtimeStore = RuntimeSharedStore>() + private val persistenceStore = MockStateDataStore(default = emptyMap()) + + private val store = DefaultQuotesStoreV2( + runtimeStore = runtimeStore, + persistenceDataStore = persistenceStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `refresh if runtime store is empty`() = runTest { + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + store.refresh(currenciesIds = currenciesIds) + + val runtimeExpected = currenciesIds.map(Quote::Empty).toSet() + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `refresh if runtime store contains quote with this id`() = runTest { + val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + .toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL) + + runtimeStore.store(value = setOf(quote)) + + store.refresh(currenciesIds = setOf(quote.rawCurrencyId)) + + val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE)) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store actual if runtime and cache stores contain quotes with this id`() = runTest { + val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + + runtimeStore.store( + value = setOf( + prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE), + ), + ) + + persistenceStore.updateData { + it.toMutableMap().apply { + put("BTC", prevStatus) + } + } + + val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN) + + store.storeActual(values = mapOf("BTC" to newStatus)) + + val runtimeExpected = setOf( + newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL), + ) + val persistenceExpected = mapOf("BTC" to newStatus) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected) + } + + @Test + fun `store error if runtime store is empty`() = runTest { + val currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ) + + store.storeError(currenciesIds = currenciesIds) + + val runtimeExpected = currenciesIds.map(Quote::Empty).toSet() + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } + + @Test + fun `store error if runtime store contains status with this network`() = runTest { + val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE) + + runtimeStore.store( + value = setOf( + status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE), + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ), + ) + + store.storeError( + currenciesIds = setOf( + CryptoCurrency.RawID(value = "BTC"), + CryptoCurrency.RawID(value = "ETH"), + ), + ) + + val runtimeExpected = setOf( + status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE), + Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")), + ) + + Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected) + Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap>()) + } +} \ No newline at end of file diff --git a/data/staking/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/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt index 66f0ea8b64..bf7779378a 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/QuotesUnsupportedCurrenciesIdAdapter.kt @@ -8,7 +8,7 @@ import com.tangem.datasource.api.tangemTech.models.QuotesResponse /** * Adapter to replace unsupported currencies for quotes request if it necessary */ -internal class QuotesUnsupportedCurrenciesIdAdapter { +class QuotesUnsupportedCurrenciesIdAdapter { /** * Replaces unsupported currencies id to it replacements for request diff --git a/data/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/converter/VisaActivationStatusConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt index d82a09116a..b67a8484e1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/converter/VisaActivationStatusConverter.kt @@ -1,15 +1,60 @@ package com.tangem.data.visa.converter import com.tangem.datasource.api.visa.models.response.CardActivationRemoteStateResponse +import com.tangem.domain.visa.model.VisaActivationOrderInfo import com.tangem.domain.visa.model.VisaActivationRemoteState import com.tangem.utils.converter.Converter +import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject +import javax.inject.Singleton +@Singleton class VisaActivationStatusConverter @Inject constructor() : Converter { + private val lastUpdatedAt = MutableStateFlow(null) + override fun convert(value: CardActivationRemoteStateResponse): VisaActivationRemoteState { + // handle pin code error + // either we entered pin code before with an error (WasError) or after receiving an error (InProgress) + if ( + value.status == Status.AwaitingPin.stringValue && + value.stepChangeCode != null && + value.stepChangeCode == PIN_CODE_VALIDATION_ERROR + ) { + return if (lastUpdatedAt.value != value.updatedAt) { + lastUpdatedAt.value == value.updatedAt + + VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = value.activationOrder!!.convert(), + status = VisaActivationRemoteState.AwaitingPinCode.Status.WasError, + ) + } else { + VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = value.activationOrder!!.convert(), + status = VisaActivationRemoteState.AwaitingPinCode.Status.InProgress, + ) + } + } + // TODO Will be implemented in the future return VisaActivationRemoteState.Activated } + + private fun CardActivationRemoteStateResponse.ActivationOrder.convert(): VisaActivationOrderInfo { + return VisaActivationOrderInfo( + orderId = id, + customerId = customerId, + customerWalletAddress = customerWalletAddress, + ) + } + + private enum class Status(val stringValue: String) { + AwaitingPin("AWAITING_PIN"), + // TODO complete statuses list when backend is ready + } + + private companion object { + const val PIN_CODE_VALIDATION_ERROR = 1000 + } } \ No newline at end of file diff --git a/data/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..59061799bf 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,26 @@ dependencies { implementation(deps.hilt.core) kapt(deps.hilt.kapt) + /* Tangem libraries */ + implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) + + /* 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) + + /* Tests */ + testImplementation(projects.common.test) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.turbine) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index 02c6f391db..8c735d8bad 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,44 @@ 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.pair.WcPairSdkDelegate +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.respond.WcRespondService +import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager +import com.tangem.data.walletconnect.utils.WcNamespaceConverter +import com.tangem.datasource.di.SdkMoshi import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.datasource.local.walletconnect.WalletConnectStore +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.walletconnect.model.WcMethod +import com.tangem.domain.walletconnect.model.legacy.WalletConnectSessionsRepository import com.tangem.domain.walletconnect.repository.WalletConnectRepository +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.request.WcRequestService +import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase +import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +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 +53,140 @@ internal object WalletConnectDataModule { ): WalletConnectRepository { return DefaultWalletConnectRepository(userWalletsStore, dispatchers) } + + @Provides + @Singleton + fun wcInitializeUseCase( + application: Application, + sessionsManager: DefaultWcSessionsManager, + networkService: DefaultWcRequestService, + pairSdkDelegate: WcPairSdkDelegate, + ): WcInitializeUseCase = DefaultWcInitializeUseCase( + application = application, + sessionsManager = sessionsManager, + networkService = networkService, + pairSdkDelegate = pairSdkDelegate, + ) + + @Provides + @Singleton + fun defaultWcPairUseCase( + sessionsManager: WcSessionsManager, + associateNetworksDelegate: AssociateNetworksDelegate, + caipNamespaceDelegate: CaipNamespaceDelegate, + sdkDelegate: WcPairSdkDelegate, + ): DefaultWcPairUseCase = DefaultWcPairUseCase( + sessionsManager = sessionsManager, + associateNetworksDelegate = associateNetworksDelegate, + caipNamespaceDelegate = caipNamespaceDelegate, + sdkDelegate = sdkDelegate, + ) + + @Provides + @Singleton + fun wcPairUseCase(default: DefaultWcPairUseCase): WcPairUseCase = default + + @Provides + @Singleton + fun sdkDelegate(): WcPairSdkDelegate = WcPairSdkDelegate() + + @Provides + @Singleton + fun defaultWcSessionsManager( + store: WalletConnectStore, + dispatchers: CoroutineDispatcherProvider, + legacyStore: WalletConnectSessionsRepository, + getUserWallet: GetUserWalletUseCase, + getWallets: GetWalletsUseCase, + ): DefaultWcSessionsManager { + val scope = CoroutineScope(SupervisorJob() + dispatchers.io) + return DefaultWcSessionsManager( + store = store, + dispatchers = dispatchers, + legacyStore = legacyStore, + getWallets = getWallets, + getUserWallet = getUserWallet, + 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): WcEthNetwork = WcEthNetwork( + moshi = moshi, + ) + + @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..91db31c6c8 --- /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.WcPairSdkDelegate +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 pairSdkDelegate: WcPairSdkDelegate, +) : WcInitializeUseCase { + + private val wcSdkObservers = mutableSetOf( + sessionsManager, + networkService, + pairSdkDelegate, + ) + + 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..ffe057712c --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthNetwork.kt @@ -0,0 +1,74 @@ +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.usecase.WcUseCase +import com.tangem.domain.walletconnect.usecase.WcUseCasesFlowProvider +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, +) : 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 -> TODO() + } + _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..ce3ec70378 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/AssociateNetworksDelegate.kt @@ -0,0 +1,101 @@ +package com.tangem.data.walletconnect.pair + +import com.reown.walletkit.client.Wallet +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.getNetwork +import com.tangem.data.walletconnect.model.CAIP2 +import com.tangem.data.walletconnect.model.NamespaceKey +import com.tangem.data.walletconnect.utils.WcNamespaceConverter +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.walletconnect.model.WcPairError +import com.tangem.domain.walletconnect.model.WcSessionProposal.ProposalNetwork +import com.tangem.domain.wallets.models.UserWallet +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 + .associateWith { wallet -> 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(missingNetworkName(chainId)) + return@forEach + } + val wcNetwork = blockchain.toNetwork() + if (wcNetwork == null) { + unknownRequired.add(missingNetworkName(blockchain)) + return@forEach + } + val walletNetwork = walletNetworks.find { network -> wcNetwork.id == network.id } + if (walletNetwork == null) { + missingRequired.add(wcNetwork) + } else { + required.add(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) + } else { + notAdded.add(walletNetwork) + } + } + if (unknownRequired.isNotEmpty()) throw WcPairError.UnsupportedNetworks(unknownRequired) + return ProposalNetwork( + wallet = wallet, + 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..2361baa676 --- /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.UserWallet + +internal class CaipNamespaceDelegate constructor( + private val namespaceConverters: Map, + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend fun associate( + sessionProposal: Wallet.Model.SessionProposal, + userWallet: UserWallet, + networks: List, + ): Map { + val converters = namespaceConverters.values + + val result = mutableMapOf() + + networks.map { network -> + val blockchain = Blockchain.fromId(network.id.value) + val address = walletManagersFacade.getDefaultAddress(userWallet.walletId, 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..28776d97c2 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -0,0 +1,139 @@ +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.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.UserWallet +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.receiveAsFlow +import timber.log.Timber + +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, + private val sdkDelegate: WcPairSdkDelegate, +) : WcPairUseCase { + + private val onCallTerminalAction = Channel() + + override fun pairFlow(uri: String, source: WcPairUseCase.Source): Flow { + return flow { + emit(WcPairState.Loading) + + val sdkSessionProposal = sdkDelegate.pair(uri) + .onLeft { emit(WcPairState.Error(it)) } + .getOrNull() ?: return@flow + + // check unsupported dApps, just local constant for now, finish if unsupported + if (sdkSessionProposal.name in unsupportedDApps) { + Timber.i("Unsupported DApp") + val error = WcPairState.Error(WcPairError.UnsupportedDApp) + emit(error) + return@flow + } + + val proposalState = buildProposalState(sdkSessionProposal) + .onLeft { emit(WcPairState.Error(it)) } + .getOrNull() ?: return@flow + emit(proposalState) + + // wait first terminal action and continue WC pair flow + val terminalAction = onCallTerminalAction.receiveAsFlow().first() + val sessionForApprove: WcSessionApprove? = when (terminalAction) { + is TerminalAction.Approve -> terminalAction.sessionForApprove + TerminalAction.Reject -> null + } + // finish flow if rejected above + if (sessionForApprove == null) { + sdkDelegate.rejectSession(sdkSessionProposal.proposerPublicKey) + return@flow + } + + // start flow of approving in wc sdk + emit(WcPairState.Approving.Loading(sessionForApprove)) + val either = walletKitApproveSession( + sessionForApprove = sessionForApprove, + sdkSessionProposal = sdkSessionProposal, + ).map { settledSession -> + val newSession = settledSession.session.toDomain(sessionForApprove.wallet) + sessionsManager.saveSession(newSession) + newSession + } + emit(WcPairState.Approving.Result(sessionForApprove, either)) + } + } + + override fun approve(sessionForApprove: WcSessionApprove) { + onCallTerminalAction.trySend(TerminalAction.Approve(sessionForApprove)) + } + + override fun reject() { + onCallTerminalAction.trySend(TerminalAction.Reject) + } + + private suspend fun walletKitApproveSession( + sessionForApprove: WcSessionApprove, + sdkSessionProposal: Wallet.Model.SessionProposal, + ): Either { + val namespaces = caipNamespaceDelegate.associate( + sdkSessionProposal, + sessionForApprove.wallet, + sessionForApprove.network, + ) + val sessionApprove = Wallet.Params.SessionApprove( + proposerPublicKey = sdkSessionProposal.proposerPublicKey, + namespaces = namespaces, + ) + return sdkDelegate.approve(sessionApprove) + } + + 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(wallet: UserWallet): WcSession = WcSession( + wallet = wallet, + 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/pair/WcPairSdkDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt new file mode 100644 index 0000000000..423eeb9ea3 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt @@ -0,0 +1,98 @@ +package com.tangem.data.walletconnect.pair + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.reown.walletkit.client.Wallet +import com.reown.walletkit.client.WalletKit +import com.tangem.data.walletconnect.utils.WcSdkObserver +import com.tangem.domain.walletconnect.model.WcPairError +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +class WcPairSdkDelegate : WcSdkObserver { + + private val onSessionProposal = Channel() + private val onSessionSettleResponse = Channel() + + suspend fun pair(url: String): Either = coroutineScope { + suspend fun proposalCallback() = onSessionProposal + .receiveAsFlow() + .filter { proposal -> proposal.url == url } + .first() + + val pairCall = async { sdkPair(url) } + val proposal = async { proposalCallback() } + pairCall.await().map { proposal.await() } + } + + suspend fun approve( + sessionApprove: Wallet.Params.SessionApprove, + ): Either = coroutineScope { + suspend fun approveCallback() = onSessionSettleResponse + .receiveAsFlow() + .first() + + val approveCall = async { sdkApprove(sessionApprove) } + val approveCallback = async { approveCallback() } + approveCall.await() + .onLeft { return@coroutineScope it.left() } + when (val result = approveCallback.await()) { + is Wallet.Model.SettledSessionResponse.Result -> result.right() + is Wallet.Model.SettledSessionResponse.Error -> + WcPairError.ExternalApprovalError(result.errorMessage).left() + } + } + + fun rejectSession(proposerPublicKey: String) { + WalletKit.rejectSession( + params = Wallet.Params.SessionReject( + proposerPublicKey = proposerPublicKey, + reason = "", + ), + onSuccess = {}, + onError = {}, + ) + } + + override fun onSessionProposal( + sessionProposal: Wallet.Model.SessionProposal, + verifyContext: Wallet.Model.VerifyContext, + ) { + // Triggered when wallet receives the session proposal sent by a Dapp + onSessionProposal.trySend(sessionProposal) + } + + override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) { + // Triggered when wallet receives the session settlement response from Dapp + onSessionSettleResponse.trySend(settleSessionResponse) + } + + private suspend fun sdkApprove(sessionApprove: Wallet.Params.SessionApprove): Either { + return suspendCancellableCoroutine { continuation -> + WalletKit.approveSession( + params = sessionApprove, + onSuccess = { continuation.resume(Unit.right()) }, + onError = { continuation.resume(it.throwable.toPairError()) }, + ) + } + } + + private suspend fun sdkPair(uri: String): Either { + return suspendCancellableCoroutine { continuation -> + WalletKit.pair( + params = Wallet.Params.Pair(uri), + onSuccess = { continuation.resume(Unit.right()) }, + onError = { continuation.resume(it.throwable.toPairError()) }, + ) + } + } + + private fun Throwable.toPairError() = WcPairError.ExternalApprovalError(this.localizedMessage.orEmpty()).left() +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt new file mode 100644 index 0000000000..08839fa987 --- /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.respond.WcRespondService +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 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..025e9834b2 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt @@ -0,0 +1,67 @@ +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 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()) + }, + ) + } + + override fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String) { + WalletKit.respondSessionRequest( + params = Wallet.Params.SessionRequestResponse( + sessionTopic = request.topic, + jsonRpcResponse = Wallet.Model.JsonRpcResponse.JsonRpcError( + id = request.request.id, + code = 0, + message = message, + ), + ), + onSuccess = {}, + onError = {}, + ) + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt new file mode 100644 index 0000000000..0eb8948d59 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/WcRespondService.kt @@ -0,0 +1,10 @@ +package com.tangem.data.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 + fun rejectRequestNonBlock(request: WcSdkSessionRequest, message: String = "") +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt new file mode 100644 index 0000000000..6e31f2783d --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -0,0 +1,160 @@ +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.UserWallet +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +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 getUserWallet: GetUserWalletUseCase, + 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() = combine(getWallets(), store.sessions) { wallets, inStore -> wallets to inStore } + .transform { pair -> + val (wallets, inStore) = pair + val inSdk: List = WalletKit.getListOfActiveSessions() + if (oneTimeMigration.value) { + oneTimeMigration.value = false + val someMigrated = migrateLegacyStore(inStore, inSdk, wallets) + if (someMigrated) return@transform // ignore emit, wait next one + } + val associatedSessions: List = associate(inSdk, inStore, wallets) + val someRemove = removeUnknownSessions(inStore, associatedSessions) + if (someRemove) return@transform // ignore emit, wait next one + emit(associatedSessions.groupBy { it.wallet }) + } + .distinctUntilChanged() + .flowOn(dispatchers.io) + + override fun onWcSdkInit() { + oneTimeMigration.value = true + listenOnSessionDelete() + } + + override suspend fun saveSession(session: WcSession) { + store.saveSession(WcSessionDTO(session.sdkModel.topic, session.wallet.walletId)) + } + + override suspend fun removeSession(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 + val wallet = getUserWallet.invoke(storedSessions.walletId).getOrNull() ?: return@withContext null + WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession)) + } + + override fun onSessionDelete(sessionDelete: Wallet.Model.SessionDelete) { + Timber.i("onSessionDelete: $sessionDelete") + onSessionDelete.trySend(sessionDelete) + } + + private suspend fun migrateLegacyStore( + inNewStore: Set, + inSdk: List, + wallets: List, + ): Boolean { + val walletIds = wallets.map { wallet -> wallet.walletId } + val inLegacyStore = walletIds + .map { walletId -> + flow { emit(legacyStore.loadSessions(walletId.stringValue).map { WcSessionDTO(it.topic, walletId) }) } + } + .merge() + .reduce { accumulator, value -> accumulator.plus(value) } + // migrate only active legacySessions + .filter { legacySession -> inSdk.any { inSdkSession -> inSdkSession.topic == legacySession.topic } } + + val mustSaveInNewStore = inLegacyStore.subtract(inNewStore) + if (mustSaveInNewStore.isNotEmpty()) store.saveSessions(mustSaveInNewStore) + return mustSaveInNewStore.isNotEmpty() + } + + private fun associate( + inSdk: List, + inStore: Set, + wallets: List, + ): List { + val wcSessions = inStore.mapNotNull { session -> + val wallet = wallets.find { it.walletId == session.walletId } ?: return@mapNotNull null + val sdkSession = inSdk.find { it.topic == session.topic } ?: return@mapNotNull null + WcSession(wallet = wallet, sdkModel = WcSdkSessionConverter.convert(sdkSession)) + } + return wcSessions + } + + 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) { + 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/sign/BaseWcSignUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BaseWcSignUseCase.kt new file mode 100644 index 0000000000..dd432f1df6 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BaseWcSignUseCase.kt @@ -0,0 +1,65 @@ +package com.tangem.data.walletconnect.sign + +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignUseCase +import kotlinx.coroutines.flow.FlowCollector + +internal abstract class BaseWcSignUseCase : + WcMethodUseCase, + WcSignUseCase.FinalAction, + FinalActionCollector, + MiddleActionCollector { + + abstract val respondService: WcRespondService + + abstract val context: WcMethodUseCaseContext + override val session: WcSession get() = context.session + override val rawSdkRequest: WcSdkSessionRequest get() = context.rawSdkRequest + + protected val delegate by lazy { + WcSignUseCaseDelegate( + finalActionCollector = this, + middleActionCollector = this, + ) + } + + override val onCancel: suspend (currentState: WcSignState) -> Unit = { + defaultReject() + } + + override fun sign() = delegate.sign() + override fun cancel() = delegate.cancel() + protected fun middleAction(action: MiddleAction) = delegate.middleAction(action) + + protected fun defaultReject() { + respondService.rejectRequestNonBlock(rawSdkRequest) + } +} + +internal interface MiddleActionCollector { + + val onMiddleAction: OnMiddle get() = { _, _ -> } +} + +internal interface FinalActionCollector { + + val onSign: OnSign get() = {} + + val onCancel: OnCancel get() = {} +} + +internal class WcMethodUseCaseContext( + val session: WcSession, + val rawSdkRequest: WcSdkSessionRequest, +) + +internal typealias OnSign = + suspend FlowCollector>.(state: WcSignState) -> Unit +internal typealias OnCancel = + suspend (currentState: WcSignState) -> Unit +internal typealias OnMiddle = + suspend FlowCollector.(currentState: WcSignState, middleAction: MiddleAction) -> Unit \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt new file mode 100644 index 0000000000..f77af76789 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/SignStateConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.data.walletconnect.sign + +import arrow.core.Either +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep + +object SignStateConverter { + + internal fun preSign(signModel: M) = WcSignState(signModel, WcSignStep.PreSign) + internal fun signing(signModel: M) = WcSignState(signModel, WcSignStep.Signing) + internal fun result(result: Either, signModel: M) = + WcSignState(signModel, WcSignStep.Result(result)) + + internal fun WcSignState.toPreSign(signModel: M = this.signModel) = copy( + signModel = signModel, + domainStep = WcSignStep.PreSign, + ) + + internal fun WcSignState.toSigning(signModel: M = this.signModel) = copy( + domainStep = WcSignStep.Signing, + signModel = signModel, + ) + + internal fun WcSignState.toResult(result: Either, signModel: M = this.signModel) = copy( + domainStep = WcSignStep.Result(result), + signModel = signModel, + ) +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt new file mode 100644 index 0000000000..c84bc39ac7 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/WcSignUseCaseDelegate.kt @@ -0,0 +1,91 @@ +package com.tangem.data.walletconnect.sign + +import arrow.core.left +import com.tangem.data.walletconnect.sign.SignStateConverter.toPreSign +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +internal class WcSignUseCaseDelegate( + private val finalActionCollector: FinalActionCollector, + private val middleActionCollector: MiddleActionCollector, +) : FinalActionCollector by finalActionCollector, + MiddleActionCollector by middleActionCollector { + + private val middleActionsChannel = Channel() + private val finalActionsChannel = Channel() + + fun cancel() { + finalActionsChannel.trySend(Action.Cancel) + } + + fun sign() { + finalActionsChannel.trySend(Action.Sign) + } + + fun middleAction(action: MiddleAction) { + middleActionsChannel.trySend(action) + } + + operator fun invoke(initModel: SignModel) = channelFlow { + val state = MutableStateFlow(WcSignState(initModel, WcSignStep.PreSign)) + + state + .onEach { newState -> channel.send(newState) } + .launchIn(this) + + fun listenMiddle() = middleActionsChannel.receiveAsFlow() + .buffer() + .transform { middleActions -> this.onMiddleAction(state.value, middleActions) } + .onEach { updatedModel -> state.update { it.toPreSign(updatedModel) } } + .launchIn(this) + + var listenMiddleJob: Job = listenMiddle() + + fun signFlow() = flow { onSign(state.updateAndGet { it.toSigning() }) } + .onEach { newState -> state.update { newState } } + .catch { exception -> + val errorResult = state.value.toResult(exception.left()) + state.update { errorResult } + } + + var signJob: Job? = null + + finalActionsChannel.receiveAsFlow() + .transformLatest { finalAction -> + when (finalAction) { + Action.Cancel -> { + onCancel.invoke(state.value) + channel.close() + } + Action.Sign -> { + val isSigningNow = signJob?.isActive == true + if (isSigningNow) return@transformLatest + listenMiddleJob.cancel() + signJob = launch { + signFlow().collect() + listenMiddleJob = listenMiddle() + } + } + } + } + .launchIn(this) + + /** + * keep flow running to attempt re-signing after an error + * or do something after a successful sign + */ + awaitClose() + } + + sealed interface Action { + data object Cancel : Action + data object Sign : Action + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/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/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt new file mode 100644 index 0000000000..86ed85e03e --- /dev/null +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt @@ -0,0 +1,232 @@ +package com.tangem.domain.walletconnect + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import com.reown.walletkit.client.Wallet +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.data.walletconnect.pair.AssociateNetworksDelegate +import com.tangem.data.walletconnect.pair.CaipNamespaceDelegate +import com.tangem.data.walletconnect.pair.DefaultWcPairUseCase +import com.tangem.data.walletconnect.pair.WcPairSdkDelegate +import com.tangem.data.walletconnect.utils.WcSdkSessionConverter +import com.tangem.domain.walletconnect.model.WcPairError +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.WcSessionApprove +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.usecase.pair.WcPairState +import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase +import io.mockk.coEvery +import io.mockk.coVerifyOrder +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class DefaultWcPairUseCaseTest { + + private val sessionsManager: WcSessionsManager = mockk() + private val associateNetworksDelegate: AssociateNetworksDelegate = mockk() + private val caipNamespaceDelegate: CaipNamespaceDelegate = mockk() + private val sdkDelegate: WcPairSdkDelegate = mockk() + + private val url = "testUrl" + private val source = WcPairUseCase.Source.QR + private val loading = WcPairState.Loading + private val sdkProposal: Wallet.Model.SessionProposal + get() = Wallet.Model.SessionProposal( + pairingTopic = "", + name = "", + description = "", + url = "", + icons = listOf(), + redirect = "", + requiredNamespaces = mapOf(), + optionalNamespaces = mapOf(), + properties = mapOf(), + proposerPublicKey = "", + relayProtocol = "", + relayData = "", + ) + + private val unsupportedDApp = "Apex Pro" + private val unsupportedSdkProposal get() = sdkProposal.copy(name = unsupportedDApp) + + private val sessionForApprove: WcSessionApprove + get() = WcSessionApprove( + wallet = MockUserWalletFactory.create(), + network = listOf(), + ) + + private val sdkApprove: Wallet.Params.SessionApprove + get() = Wallet.Params.SessionApprove( + proposerPublicKey = "", + namespaces = mapOf(), + ) + + private val sdkApproveSuccess: Wallet.Model.SettledSessionResponse.Result + get() = Wallet.Model.SettledSessionResponse.Result( + session = sdkSession, + ) + + private val sdkSession: Wallet.Model.Session + get() = Wallet.Model.Session( + pairingTopic = "", + topic = "", + expiry = 0L, + requiredNamespaces = mapOf(), + optionalNamespaces = mapOf(), + namespaces = mapOf(), + metaData = null, + ) + + private val Wallet.Model.Session.sessionForSave: WcSession + get() = WcSession( + wallet = sessionForApprove.wallet, + sdkModel = WcSdkSessionConverter.convert(this), + ) + + private val useCase = DefaultWcPairUseCase( + sessionsManager = sessionsManager, + associateNetworksDelegate = associateNetworksDelegate, + caipNamespaceDelegate = caipNamespaceDelegate, + sdkDelegate = sdkDelegate, + ) + + @Before + fun setup() { + coEvery { associateNetworksDelegate.associate(sdkProposal) } returns mapOf() + coEvery { + caipNamespaceDelegate.associate( + sessionProposal = sdkProposal, + userWallet = sessionForApprove.wallet, + networks = sessionForApprove.network, + ) + } returns mapOf() + } + + @Test + fun `pair, emmit proposal state and wait actions`() = runTest { + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assert(awaitItem() is WcPairState.Proposal) + expectNoEvents() + } + } + + @Test + fun `success pair and approve flow`() = runTest { + val approveLoading = WcPairState.Approving.Loading(sessionForApprove) + val sessionForSave = sdkSession.sessionForSave + val result = WcPairState.Approving.Result(sessionForApprove, sessionForSave.right()) + + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.approve(sdkApprove) } returns sdkApproveSuccess.right() + coEvery { sessionsManager.saveSession(sessionForSave) } returns Unit + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.approve(sessionForApprove) + // ignore + useCase.reject() + + assertEquals(approveLoading, awaitItem()) + coVerifyOrder { + sdkDelegate.approve(sdkApprove) + sessionsManager.saveSession(sessionForSave) + } + assertEquals(result, awaitItem()) + awaitComplete() + } + } + + @Test + fun `success pair and reject approving`() = runTest { + val proposerPublicKey = sdkProposal.proposerPublicKey + + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.rejectSession(proposerPublicKey) } returns Unit + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.reject() + coVerifyOrder { + sdkDelegate.rejectSession(sdkProposal.proposerPublicKey) + } + awaitComplete() + } + } + + @Test + fun `success pair and reject unsupported dApp`() = runTest { + coEvery { sdkDelegate.pair(url) } returns unsupportedSdkProposal.right() + val unsupportedDAppError = WcPairState.Error(WcPairError.UnsupportedDApp) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assertEquals(unsupportedDAppError, awaitItem()) + awaitComplete() + } + } + + @Test + fun `complete on pair error`() = runTest { + val error = WcPairError.ExternalApprovalError("error") + coEvery { sdkDelegate.pair(url) } returns error.left() + val errorState = WcPairState.Error(error) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + } + assertEquals(errorState, awaitItem()) + awaitComplete() + } + } + + @Test + fun `complete on approve error`() = runTest { + val approveLoading = WcPairState.Approving.Loading(sessionForApprove) + val error = WcPairError.ExternalApprovalError("error").left() + coEvery { sdkDelegate.pair(url) } returns sdkProposal.right() + coEvery { sdkDelegate.approve(sdkApprove) } returns error + + val errorResult = WcPairState.Approving.Result(sessionForApprove, error) + + useCase.pairFlow(url, source).test { + assertEquals(loading, awaitItem()) + coVerifyOrder { + sdkDelegate.pair(url) + associateNetworksDelegate.associate(sdkProposal) + } + assert(awaitItem() is WcPairState.Proposal) + useCase.approve(sessionForApprove) + + assertEquals(approveLoading, awaitItem()) + coVerifyOrder { + sdkDelegate.approve(sdkApprove) + } + + assertEquals(errorResult, awaitItem()) + awaitComplete() + } + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt new file mode 100644 index 0000000000..e80bd9a4f8 --- /dev/null +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/WcSignUseCaseDelegateTest.kt @@ -0,0 +1,254 @@ +package com.tangem.domain.walletconnect + +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import com.tangem.data.walletconnect.sign.FinalActionCollector +import com.tangem.data.walletconnect.sign.MiddleActionCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.SignStateConverter.toSigning +import com.tangem.data.walletconnect.sign.WcSignUseCaseDelegate +import com.tangem.domain.walletconnect.usecase.sign.WcSignState +import com.tangem.domain.walletconnect.usecase.sign.WcSignStep +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +internal class WcSignUseCaseDelegateTest { + + private val middleActionCollector = mockk>() + private val finalActionCollector = mockk>() + private val useCase = WcSignUseCaseDelegate( + finalActionCollector = finalActionCollector, + middleActionCollector = middleActionCollector, + ) + private val initSignModel = TestSignModel() + + private val initState = WcSignState(initSignModel, WcSignStep.PreSign) + private val signing = initState.toSigning() + private val result = signing.toResult(Unit.right()) + private val testException = RuntimeException("test") + + private val successSign: suspend FlowCollector>.( + currentState: WcSignState, + ) -> Unit = { state -> + delay(2) + emit(state.toResult(Unit.right())) + } + + private val failedSign: suspend FlowCollector>.( + currentState: WcSignState, + ) -> Unit + get() = { state -> + delay(2) + emit(state.toResult(testException.left())) + } + + @Before + fun setup() { + every { middleActionCollector.onMiddleAction } returns { _, _ -> } + every { finalActionCollector.onSign } returns { } + every { finalActionCollector.onCancel } returns { } + } + + @Test + fun `invoke and keep flow running`() = runTest { + every { finalActionCollector.onSign } returns successSign + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `success sign, keep flow running`() = runTest { + every { finalActionCollector.onSign } returns successSign + + useCase.invoke(initModel = initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(result, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `failed sign, keep flow running`() = runTest { + val failedResult = signing.toResult(testException.left()) + every { finalActionCollector.onSign } returns failedSign + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(failedResult, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `failed sign and catch unknown exception`() = runTest { + val exception = RuntimeException("asd") + val expectedErrorState = signing.toResult(exception.left()) + every { finalActionCollector.onSign } returns { + delay(2) + throw exception + } + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + assertEquals(expectedErrorState, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `interrupt signing and complete flow on cancel call`() = runTest { + every { finalActionCollector.onSign } returns { + delay(5) + emit(result) + } + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.sign() + assertEquals(signing, awaitItem()) + delay(2) + useCase.cancel() + awaitComplete() + } + } + + @Test + fun `ignore multi time sign call till signed`() = runTest { + var count = 0 + val startLoading = WcSignState(TestSignModel("startLoading 1"), WcSignStep.Signing) + val startLoading2 = WcSignState(TestSignModel("startLoading 2"), WcSignStep.Signing) + val expectedSignResult = result + + every { finalActionCollector.onSign } returns { + // should emit single time in this test + emit(if (count % 2 == 0) startLoading else startLoading2) + count = count.inc() + delay(10) + emit(expectedSignResult) + } + + useCase.invoke(initSignModel).test { + useCase.sign() + delay(2) + assertEquals(startLoading, expectMostRecentItem()) + + // should ignore + useCase.sign() + delay(2) + expectNoEvents() + + // should ignore + useCase.sign() + expectNoEvents() + + delay(8) + assertEquals(expectedSignResult, expectMostRecentItem()) + expectNoEvents() + } + } + + @Test + fun `ignore middle actions while signing, on failed collect middle actions again`() = runTest { + val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr) + val firstMiddleUpdate = WcSignState( + signModel = firstTextMode, + domainStep = WcSignStep.PreSign, + ) + val startLoading = firstMiddleUpdate.toSigning() + val failedSign = startLoading.toResult(testException.left()) + val thirdMiddleUpdate = WcSignState( + signModel = TestSignModel(TestMiddleAction.Three().newTestStr), + domainStep = WcSignStep.PreSign, + ) + + every { finalActionCollector.onSign } returns { + delay(6) + emit(failedSign) + } + + every { middleActionCollector.onMiddleAction } returns { currentState, middleAction -> + emit(currentState.signModel.copy(testStr = middleAction.newTestStr)) + } + + useCase.invoke(initSignModel).test { + delay(2) + useCase.middleAction(TestMiddleAction.One()) + assertEquals(firstMiddleUpdate, expectMostRecentItem()) + + useCase.sign() + assertEquals(startLoading, awaitItem()) + + // should ignore + delay(2) + useCase.middleAction(TestMiddleAction.Two()) + + delay(3) + assertEquals(failedSign, awaitItem()) + + // continue listen + delay(2) + useCase.middleAction(TestMiddleAction.Three()) + assertEquals(thirdMiddleUpdate, awaitItem()) + } + } + + @Test + fun `buffered middle actions and drop on sign call`() = runTest { + val firstTextMode = TestSignModel(TestMiddleAction.One().newTestStr) + val expectedFirst = initState.copy(signModel = firstTextMode) + val expectedSecond = initState.copy(signModel = TestSignModel(TestMiddleAction.Two().newTestStr)) + + every { finalActionCollector.onSign } returns successSign + every { middleActionCollector.onMiddleAction } returns { currentState, middleAction -> + emit(currentState.signModel.copy(testStr = middleAction.newTestStr)) + delay(4) + } + + useCase.invoke(initSignModel).test { + assertEquals(initState, awaitItem()) + useCase.middleAction(TestMiddleAction.One()) + useCase.middleAction(TestMiddleAction.Two()) + // must be dropped + useCase.middleAction(TestMiddleAction.Three()) + + // 0 - 4 -> "one" is emitted + // 4 - 8 -> "two" is emitted + // 8 - 12 -> "Signing" is emitted, "three" ignored + delay(2) + assertEquals(expectedFirst, awaitItem()) + delay(4) + assertEquals(expectedSecond, awaitItem()) + + useCase.sign() + delay(4) + assertEquals(expectedSecond.toSigning(), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + internal data class TestSignModel(val testStr: String = "testStr") + + internal sealed interface TestMiddleAction { + val newTestStr: String + + data class One(override val newTestStr: String = "Middle Action One") : TestMiddleAction + data class Two(override val newTestStr: String = "Middle Action Two") : TestMiddleAction + data class Three(override val newTestStr: String = "Middle Action Three") : TestMiddleAction + } +} \ No newline at end of file diff --git a/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/build.gradle.kts b/domain/feedback/build.gradle.kts index 28fe483e00..213c40c655 100644 --- a/domain/feedback/build.gradle.kts +++ b/domain/feedback/build.gradle.kts @@ -15,4 +15,5 @@ dependencies { implementation(projects.core.res) implementation(projects.domain.models) implementation(projects.domain.wallets.models) + implementation(projects.domain.visa.models) } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt index c2726a604f..7d0f5aa49f 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/FeedbackDataBuilder.kt @@ -2,12 +2,43 @@ package com.tangem.domain.feedback import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.utils.breakLine +import com.tangem.domain.visa.model.VisaTxDetails import com.tangem.domain.feedback.models.BlockchainInfo.Addresses as BlockchainAddresses internal class FeedbackDataBuilder { private val builder = StringBuilder() + fun addVisaTxInfo(txDetails: VisaTxDetails) { + builder.appendKeyValue("Type", txDetails.type) + builder.appendKeyValue("Status", txDetails.status) + builder.appendKeyValue("Blockchain amount", txDetails.blockchainAmount.toString()) + builder.appendKeyValue("Transaction amount", txDetails.transactionAmount.toString()) + builder.appendKeyValue("Currency code", txDetails.transactionCurrencyCode.toString()) + builder.appendKeyValue("Merchant name", txDetails.merchantName) + builder.appendKeyValue("Merchant city", txDetails.merchantCity) + builder.appendKeyValue("Merchant country code", txDetails.merchantCountryCode) + builder.appendKeyValue("Merchant category code", txDetails.merchantCategoryCode) + + builder.appendDelimiter() + builder.breakLine() + builder.append("Requests:") + + txDetails.requests.forEach { request -> + builder.appendKeyValue("Type", request.requestType) + builder.appendKeyValue("Status", request.requestStatus) + builder.appendKeyValue("Blockchain amount", request.blockchainAmount.toString()) + builder.appendKeyValue("Transaction amount", request.transactionAmount.toString()) + builder.appendKeyValue("Currency code", request.txCurrencyCode.toString()) + builder.appendKeyValue("Error code", request.errorCode.toString()) + builder.appendKeyValue("Date", request.requestDate.toString()) + builder.appendKeyValue("Transaction hash", request.txHash) + builder.appendKeyValue("Transaction status", request.txStatus) + builder.appendDelimiter() + builder.breakLine() + } + } + fun addUserWalletsInfo(userWalletsInfo: UserWalletsInfo) { builder.appendKeyValue("User Wallet ID", userWalletsInfo.selectedUserWalletId) builder.appendKeyValue("Total saved wallets", userWalletsInfo.totalUserWallets.toString()) diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt index 570f0f9b56..a94664bc9a 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/SendFeedbackEmailUseCase.kt @@ -2,7 +2,6 @@ package com.tangem.domain.feedback import android.content.res.Resources import com.tangem.core.res.getStringSafe -import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmail import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackRepository @@ -27,18 +26,22 @@ class SendFeedbackEmailUseCase( suspend operator fun invoke(type: FeedbackEmailType) { val email = FeedbackEmail( - address = getAddress(type.cardInfo), + address = getAddress(type), subject = emailSubjectResolver.resolve(type), message = createMessage(type), // Temporally user data is not sent - file = null, // feedbackRepository.getLogFile(), + file = null, // feedbackRepository.getZipLogFile(), ) feedbackRepository.sendEmail(email) } - private fun getAddress(cardInfo: CardInfo?): String { - return if (cardInfo?.isStart2Coin == true) START2COIN_SUPPORT_EMAIL else TANGEM_SUPPORT_EMAIL + private fun getAddress(type: FeedbackEmailType): String { + return when { + type is FeedbackEmailType.Visa || type.cardInfo?.isVisa == true -> TANGEM_VISA_SUPPORT_EMAIL + type.cardInfo?.isStart2Coin == true -> START2COIN_SUPPORT_EMAIL + else -> TANGEM_SUPPORT_EMAIL + } } private suspend fun createMessage(type: FeedbackEmailType): String { @@ -61,12 +64,15 @@ class SendFeedbackEmailUseCase( is FeedbackEmailType.CurrencyDescriptionError, is FeedbackEmailType.PreActivatedWallet, is FeedbackEmailType.CardAttestationFailed, + is FeedbackEmailType.Visa.Dispute, -> this is FeedbackEmailType.DirectUserRequest, is FeedbackEmailType.RateCanBeBetter, is FeedbackEmailType.StakingProblem, is FeedbackEmailType.SwapProblem, is FeedbackEmailType.TransactionSendingProblem, + is FeedbackEmailType.Visa.Activation, + is FeedbackEmailType.Visa.DirectUserRequest, -> { append(resources.getStringSafe(R.string.feedback_data_collection_message)) skipLine() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt index 681cb957c0..d6d83ea329 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/CardInfo.kt @@ -11,6 +11,7 @@ data class CardInfo( val signedHashesList: List, val isImported: Boolean, val isStart2Coin: Boolean, + val isVisa: Boolean, ) { data class SignedHashes(val curve: String, val total: String?) diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt index 364c363194..7853427ca5 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/models/FeedbackEmailType.kt @@ -1,5 +1,7 @@ package com.tangem.domain.feedback.models +import com.tangem.domain.visa.model.VisaTxDetails + /** * Email feedback type * @@ -52,4 +54,15 @@ sealed interface FeedbackEmailType { data object CardAttestationFailed : FeedbackEmailType { override val cardInfo: CardInfo? = null } + + sealed class Visa : FeedbackEmailType { + data class DirectUserRequest(override val cardInfo: CardInfo) : Visa() + + data class Activation(override val cardInfo: CardInfo) : Visa() + + data class Dispute( + val visaTxDetails: VisaTxDetails, + override val cardInfo: CardInfo, + ) : Visa() + } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/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/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index e531f71d3f..7711abe1d1 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -4,6 +4,7 @@ import com.tangem.domain.feedback.FeedbackDataBuilder import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.visa.model.VisaTxDetails /** * Email message body resolver @@ -29,11 +30,20 @@ internal class EmailMessageBodyResolver( is FeedbackEmailType.ScanningProblem, is FeedbackEmailType.CardAttestationFailed, -> addPhoneInfoBody() + is FeedbackEmailType.Visa.Activation -> addUserRequestBody(type.cardInfo) + is FeedbackEmailType.Visa.DirectUserRequest -> addUserRequestBody(type.cardInfo) + is FeedbackEmailType.Visa.Dispute -> addVisaRequestBody(type.cardInfo, type.visaTxDetails) } return build() } + private suspend fun FeedbackDataBuilder.addVisaRequestBody(cardInfo: CardInfo, visaTxDetails: VisaTxDetails) { + addUserRequestBody(cardInfo) + addDelimiter() + addVisaTxInfo(visaTxDetails) + } + private suspend fun FeedbackDataBuilder.addUserRequestBody(cardInfo: CardInfo) { addUserWalletsInfo(userWalletsInfo = feedbackRepository.getUserWalletsInfo(cardInfo.userWalletId)) addDelimiter() diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt index 30058b611e..1708558099 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageTitleResolver.kt @@ -20,6 +20,9 @@ internal class EmailMessageTitleResolver(private val resources: Resources) { is FeedbackEmailType.DirectUserRequest, is FeedbackEmailType.CurrencyDescriptionError, is FeedbackEmailType.CardAttestationFailed, + is FeedbackEmailType.Visa.Activation, + is FeedbackEmailType.Visa.DirectUserRequest, + is FeedbackEmailType.Visa.Dispute, -> R.string.feedback_preface_support is FeedbackEmailType.RateCanBeBetter -> R.string.feedback_preface_rate_negative is FeedbackEmailType.ScanningProblem -> R.string.feedback_preface_scan_failed diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt index cc2b6c379c..849d1ce0c4 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailSubjectResolver.kt @@ -37,6 +37,9 @@ internal class EmailSubjectResolver(private val resources: Resources) { resources.getStringSafe(R.string.feedback_token_description_error) } FeedbackEmailType.CardAttestationFailed -> "Card attestation failed" + is FeedbackEmailType.Visa.Activation -> "[Visa] [Activation] {auto-filled subject}" + is FeedbackEmailType.Visa.DirectUserRequest -> "[Visa] {auto-filled subject}" + is FeedbackEmailType.Visa.Dispute -> "[Visa] [DISPUTE] {auto-filled subject}" } } } \ No newline at end of file diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt index aeaa917e5f..e1ec372cc9 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/FeedbackConstants.kt @@ -3,4 +3,5 @@ package com.tangem.domain.feedback.utils internal const val GMAIL_MAX_FILE_SIZE = 24_900_000 // ≈ 25 MB internal const val START2COIN_SUPPORT_EMAIL = "cardsupport@start2coin.com" -internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com" \ No newline at end of file +internal const val TANGEM_SUPPORT_EMAIL = "support@tangem.com" +internal const val TANGEM_VISA_SUPPORT_EMAIL = "pay@tangem.com" \ No newline at end of file diff --git a/domain/legacy/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/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt new file mode 100644 index 0000000000..777d3355d8 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/ArtworkModel.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.models + +data class ArtworkModel( + val verifiedArtwork: ByteArray? = null, + val defaultUrl: String, +) \ No newline at end of file diff --git a/domain/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..2c75ead2f4 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTAsset.kt @@ -2,53 +2,70 @@ 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 class Solana( + val tokenAddress: String, + val cnft: Boolean, + ) : Identifier() { + override val stringValue: String = tokenAddress + } + + @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..eee0569d01 100644 --- a/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt +++ b/domain/nft/models/src/main/kotlin/com/tangem/domain/nft/models/NFTCollection.kt @@ -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,38 @@ 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 class Solana(val collection: 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/quotes/.gitignore b/domain/quotes/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/quotes/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/quotes/build.gradle.kts b/domain/quotes/build.gradle.kts new file mode 100644 index 0000000000..8f7c2b07d7 --- /dev/null +++ b/domain/quotes/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + api(projects.domain.core) + api(projects.domain.tokens.models) +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt new file mode 100644 index 0000000000..4520ad1fd6 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/multi/MultiQuoteFetcher.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.quotes.multi + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.tokens.model.CryptoCurrency + +/** + * Fetcher of quotes + * +[REDACTED_AUTHOR] + */ +interface MultiQuoteFetcher : FlowFetcher { + + data class Params(val currenciesIds: Set) +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt new file mode 100644 index 0000000000..7185ea9577 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteProducer.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.quotes.single + +import com.tangem.domain.core.flow.FlowProducer +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote + +/** + * Producer of quote [CryptoCurrency.RawID] + * +[REDACTED_AUTHOR] + */ +interface SingleQuoteProducer : FlowProducer { + + data class Params(val rawCurrencyId: CryptoCurrency.RawID) + + interface Factory : FlowProducer.Factory +} \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt new file mode 100644 index 0000000000..e65dbb492d --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/single/SingleQuoteSupplier.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.quotes.single + +import com.tangem.domain.core.flow.FlowCachingSupplier +import com.tangem.domain.tokens.model.Quote + +/** + * Supplier of quote [SingleQuoteProducer.Params] + * + * @property factory factory for creating [SingleQuoteProducer] + * @property keyCreator key creator + * +[REDACTED_AUTHOR] + */ +abstract class SingleQuoteSupplier( + override val factory: SingleQuoteProducer.Factory, + override val keyCreator: (SingleQuoteProducer.Params) -> String, +) : FlowCachingSupplier() \ No newline at end of file diff --git a/domain/staking/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/Quote.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt index ebfb2e7525..6e3d9532f0 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Quote.kt @@ -7,6 +7,13 @@ sealed interface Quote { val rawCurrencyId: CryptoCurrency.RawID + fun copySealed(source: StatusSource): Quote { + return when (this) { + is Empty -> this + is Value -> copy(source = source) + } + } + /** * Represents unknown financial information for a specific cryptocurrency. * diff --git a/domain/tokens/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..be40961b10 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/TokensFeatureToggles.kt @@ -7,5 +7,9 @@ package com.tangem.domain.tokens */ interface TokensFeatureToggles { - val isBalancesCachingEnabled: Boolean + val isNetworksLoadingRefactoringEnabled: Boolean + + val isQuotesLoadingRefactoringEnabled: Boolean + + val isStakingLoadingRefactoringEnabled: Boolean } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/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..b515f71dd9 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,15 @@ internal class GetPrimaryCurrencyStatusUpdatesUseCaseTest { quotesRepository = MockQuotesRepository(quotes), networksRepository = MockNetworksRepository(statuses), stakingRepository = MockStakingRepository(), + + tokensFeatureToggles = object : TokensFeatureToggles { + override val isNetworksLoadingRefactoringEnabled: Boolean = false + override val isQuotesLoadingRefactoringEnabled: Boolean = false + override val isStakingLoadingRefactoringEnabled: 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..2de3acff50 100644 --- a/domain/visa/models/build.gradle.kts +++ b/domain/visa/models/build.gradle.kts @@ -10,4 +10,6 @@ dependencies { ksp(deps.moshi.kotlin.codegen) implementation(deps.moshi.adapters) implementation(deps.kotlin.serialization) + implementation(deps.jodatime) + implementation(projects.core.error) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/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/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt index 78bbd9692b..76389945fd 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationOrderInfo.kt @@ -8,6 +8,6 @@ import kotlinx.serialization.Serializable @JsonClass(generateAdapter = true) data class VisaActivationOrderInfo( @Json(name = "orderId") val orderId: String, - @Json(name = "customer_id") val customerId: String, - @Json(name = "customer_wallet_address") val customerWalletAddress: String, + @Json(name = "customerId") val customerId: String, + @Json(name = "customerWalletAddress") val customerWalletAddress: String, ) \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt index 88e4389287..889037bee3 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaActivationRemoteState.kt @@ -20,9 +20,14 @@ sealed class VisaActivationRemoteState { data object PaymentAccountDeploying : VisaActivationRemoteState() @Serializable - data class WaitingPinCode( + data class AwaitingPinCode( val activationOrderInfo: VisaActivationOrderInfo, - ) : VisaActivationRemoteState() + val status: Status, + ) : VisaActivationRemoteState() { + enum class Status { + WaitingForPinCode, InProgress, WasError + } + } @Serializable data object WaitingForActivationFinishing : VisaActivationRemoteState() @@ -43,6 +48,7 @@ class VisaActivationRemoteState_Json( @Json(name = "type") val type: VisaActivationRemoteState_Type, @Json(name = "requestCardWallet") val requestCardWallet: VisaCardWalletDataToSignRequest? = null, @Json(name = "activationOrderInfo") val activationOrderInfo: VisaActivationOrderInfo? = null, + @Json(name = "awaiting_pin_code_status") val awaitingPinCodeStatus: AwaitingPinCodeStatus_Type? = null, ) @Suppress("ClassNaming") @@ -58,6 +64,12 @@ enum class VisaActivationRemoteState_Type { BlockedForActivation, } +@Suppress("ClassNaming") +@JsonClass(generateAdapter = false) +enum class AwaitingPinCodeStatus_Type { + WaitingForPinCode, InProgress, WasError +} + @Suppress("ClassNaming") class VisaActivationRemoteState_JsonAdapter { @@ -70,7 +82,17 @@ class VisaActivationRemoteState_JsonAdapter { VisaActivationRemoteState.CustomerWalletSignatureRequired(value.activationOrderInfo!!) VisaActivationRemoteState_Type.PaymentAccountDeploying -> VisaActivationRemoteState.PaymentAccountDeploying VisaActivationRemoteState_Type.WaitingPinCode -> - VisaActivationRemoteState.WaitingPinCode(value.activationOrderInfo!!) + VisaActivationRemoteState.AwaitingPinCode( + activationOrderInfo = value.activationOrderInfo!!, + status = when (value.awaitingPinCodeStatus!!) { + AwaitingPinCodeStatus_Type.WaitingForPinCode -> + VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode + AwaitingPinCodeStatus_Type.InProgress -> + VisaActivationRemoteState.AwaitingPinCode.Status.InProgress + AwaitingPinCodeStatus_Type.WasError -> + VisaActivationRemoteState.AwaitingPinCode.Status.WasError + }, + ) VisaActivationRemoteState_Type.WaitingForActivationFinishing -> VisaActivationRemoteState.WaitingForActivationFinishing VisaActivationRemoteState_Type.Activated -> VisaActivationRemoteState.Activated @@ -93,10 +115,18 @@ class VisaActivationRemoteState_JsonAdapter { ) is VisaActivationRemoteState.PaymentAccountDeploying -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.PaymentAccountDeploying) - is VisaActivationRemoteState.WaitingPinCode -> + is VisaActivationRemoteState.AwaitingPinCode -> VisaActivationRemoteState_Json( VisaActivationRemoteState_Type.WaitingPinCode, activationOrderInfo = value.activationOrderInfo, + awaitingPinCodeStatus = when (value.status) { + VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode -> + AwaitingPinCodeStatus_Type.WaitingForPinCode + VisaActivationRemoteState.AwaitingPinCode.Status.InProgress -> + AwaitingPinCodeStatus_Type.InProgress + VisaActivationRemoteState.AwaitingPinCode.Status.WasError -> + AwaitingPinCodeStatus_Type.WasError + }, ) is VisaActivationRemoteState.WaitingForActivationFinishing -> VisaActivationRemoteState_Json(VisaActivationRemoteState_Type.WaitingForActivationFinishing) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt similarity index 93% rename from domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt rename to domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt index 1e833b7c16..9664728814 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VisaTxDetails.kt @@ -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/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/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/WcPairError.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcPairError.kt new file mode 100644 index 0000000000..5a060eaaba --- /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..cc898e4618 --- /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.UserWallet + +data class WcSession( + val wallet: UserWallet, + val sdkModel: WcSdkSession, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt new file mode 100644 index 0000000000..427c4a9d89 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionApprove.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.walletconnect.model + +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet + +data class WcSessionApprove( + val wallet: UserWallet, + val network: List, +) \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/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..50d299303f --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcSessionProposal.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.walletconnect.model + +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData +import com.tangem.domain.wallets.models.UserWallet + +data class WcSessionProposal( + val dAppMetaData: WcAppMetaData, + val proposalNetwork: Map, + val securityStatus: Any, +) { + + data class ProposalNetwork( + val wallet: UserWallet, + 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..fa207f0263 --- /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.UserWallet +import kotlinx.coroutines.flow.Flow + +interface WcSessionsManager { + val sessions: Flow>> + suspend fun saveSession(session: WcSession) + suspend fun removeSession(session: WcSession): Either + suspend fun findSessionByTopic(topic: String): WcSession? +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/request/WcRequestService.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/request/WcRequestService.kt 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/usecase/WcSessionsUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcSessionsUseCase.kt new file mode 100644 index 0000000000..138c4a419f --- /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.UserWallet +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/WcUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt new file mode 100644 index 0000000000..6ff2c38c73 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/WcUseCase.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.walletconnect.usecase + +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest + +interface WcUseCase + +interface WcMethodUseCase : WcUseCase { + val session: WcSession + val rawSdkRequest: WcSdkSessionRequest +} \ 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..707dc20180 --- /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) + } +} \ 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/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignState.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignState.kt new file mode 100644 index 0000000000..047148fade --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignState.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.walletconnect.usecase.sign + +import arrow.core.Either + +data class WcSignState( + val signModel: SignModel, + val domainStep: WcSignStep, +) + +sealed interface WcSignStep { + data object PreSign : WcSignStep + data object Signing : WcSignStep + data class Result(val result: Either) : WcSignStep +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt new file mode 100644 index 0000000000..a0db5aa0e7 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/sign/WcSignUseCase.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.walletconnect.usecase.sign + +import com.tangem.domain.walletconnect.usecase.WcMethodUseCase +import kotlinx.coroutines.flow.Flow + +interface WcSignUseCase : WcMethodUseCase { + + interface FinalAction { + fun cancel() + fun sign() + } + + interface SimpleRun { + operator fun invoke(): Flow> + } + + interface ArgsRun { + operator fun invoke(args: Args): Flow> + } +} \ 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/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt index 95b44fff95..df15cfd819 100644 --- a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/UserWallet.kt @@ -10,7 +10,6 @@ import com.tangem.domain.models.scan.ScanResponse * * @property name User wallet name * @property walletId User wallet [UserWalletId] - * @property artworkUrl User wallet card artwork URL * @property cardsInWallet List of cards IDs assigned with this user's wallet. The list will be empty if the wallet * has been backed up on another device. * @property isMultiCurrency Indicates whether this user wallet can work with more than one currency @@ -22,8 +21,6 @@ data class UserWallet( val name: String, @Json(name = "walletId") val walletId: UserWalletId, - @Json(name = "artworkUrl") - val artworkUrl: String, @Json(name = "cardsInWallet") val cardsInWallet: Set, @Json(name = "isMultiCurrency") diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt index ffa69e47ae..7053d98d7d 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/builder/UserWalletBuilder.kt @@ -3,14 +3,15 @@ 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 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 var backupCardsIds: Set = emptySet() private var hasBackupError: Boolean = false @@ -35,7 +36,7 @@ class UserWalletBuilder( this.hasBackupError = hasBackupError } - suspend fun build(): UserWallet? { + fun build(): UserWallet? { return with(scanResponse) { UserWalletIdBuilder.scanResponse(scanResponse) .build() @@ -47,7 +48,6 @@ class UserWalletBuilder( isBackupNotAllowed = card.isBackupNotAllowed, isStartToCoin = cardTypesResolver.isStart2Coin(), ), - artworkUrl = getCardImageUseCase.invoke(card.cardId, card.cardPublicKey), cardsInWallet = backupCardsIds.plus(card.cardId), scanResponse = this, isMultiCurrency = cardTypesResolver.isMultiwalletAllowed(), @@ -56,4 +56,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..048729f9e9 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetCardImageUseCase.kt @@ -4,18 +4,25 @@ import com.tangem.common.extensions.toHexString import com.tangem.common.services.Result import com.tangem.domain.common.TwinCardNumber import com.tangem.domain.common.TwinsHelper +import com.tangem.domain.models.ArtworkModel import com.tangem.domain.wallets.models.Artwork +import com.tangem.operations.attestation.ArtworkSize +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.OnlineCardVerifier -import com.tangem.operations.attestation.TangemApi +import com.tangem.sdk.api.featuretoggles.CardSdkFeatureToggles /** * 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, + private val cardArtworksProvider: CardArtworksProvider, + private val cardSdkFeatureToggles: CardSdkFeatureToggles, +) { /** * Get card image url @@ -23,14 +30,34 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardV * @param cardId card id * @param cardPublicKey card public key */ - suspend operator fun invoke(cardId: String, cardPublicKey: ByteArray): String { + suspend operator fun invoke(cardId: String, cardPublicKey: ByteArray, size: ArtworkSize): ArtworkModel { + return if (cardSdkFeatureToggles.isNewArtworkLoadingEnabled) { + val result = cardArtworksProvider.getArtwork( + cardId = cardId, + cardPublicKey = cardPublicKey, + size = size, + ) + when (result) { + is Result.Failure -> ArtworkModel(null, getFallbackArtworkUrl(cardId)) + is Result.Success -> ArtworkModel(result.data, getFallbackArtworkUrl(cardId)) + } + } else { + ArtworkModel(null, getLegacyArtwork(cardId, cardPublicKey)) + } + } + + private suspend fun getLegacyArtwork(cardId: String, cardPublicKey: ByteArray): String { return when (val result = verifier.getCardInfo(cardId, cardPublicKey)) { is Result.Success -> { val artworkId = result.data.artwork?.id if (artworkId.isNullOrEmpty()) { getFallbackArtworkUrl(cardId) } else { - getUrlForArtwork(cardId, cardPublicKey.toHexString(), artworkId) + CardArtworksProvider.getUrlForArtwork( + cardId = cardId, + cardPublicKey = cardPublicKey.toHexString(), + artworkId = artworkId, + ) } } @@ -38,8 +65,6 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardV } } - fun getDefaultFallbackUrl(): String = Artwork.DEFAULT_IMG_URL - private fun getFallbackArtworkUrl(cardId: String): String { return when { cardId.startsWith(Artwork.SERGIO_CARD_ID) -> Artwork.SERGIO_CARD_URL @@ -47,13 +72,8 @@ class GetCardImageUseCase(private val verifier: OnlineCardVerifier = OnlineCardV else -> when (TwinsHelper.getTwinCardNumber(cardId)) { TwinCardNumber.First -> Artwork.TWIN_CARD_1_URL TwinCardNumber.Second -> Artwork.TWIN_CARD_2_URL - else -> getDefaultFallbackUrl() + else -> Artwork.DEFAULT_IMG_URL } } } - - 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/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index 9a4ec03248..37abe05472 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.core.decompose.navigation.DummyRouter import com.tangem.core.navigation.url.DummyUrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsUM @@ -28,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent { val previewState = DetailsUM( items = previewBlocks, footer = previewFooter, + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, popBack = { /* no-op */ }, ) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt index d9bdebec63..7d8f07f26d 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewUserWalletListComponent.kt @@ -29,7 +29,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("My Wallet"), information = getInformation(cardCount = 1), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, onClick = {}, ), @@ -38,7 +37,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("Old wallet"), information = getInformation(cardCount = 2), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, onClick = {}, ), @@ -47,7 +45,6 @@ internal class PreviewUserWalletListComponent : UserWalletListComponent { name = stringReference("Multi Card"), information = getInformation(cardCount = 3), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = false, onClick = {}, ), diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt index 15f0fead96..b7c390e3bd 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt @@ -1,9 +1,11 @@ package com.tangem.features.details.entity +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import kotlinx.collections.immutable.ImmutableList internal data class DetailsUM( val items: ImmutableList, val footer: DetailsFooterUM, + val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig, val popBack: () -> Unit, ) \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt new file mode 100644 index 0000000000..18363c2c30 --- /dev/null +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectEmailFeedbackTypeBS.kt @@ -0,0 +1,16 @@ +package com.tangem.features.details.entity + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.details.impl.R + +internal data class SelectEmailFeedbackTypeBS( + val onOptionClick: (Option) -> Unit, +) : TangemBottomSheetConfigContent { + + enum class Option(val text: TextReference) { + General(resourceReference(R.string.common_contact_tangem_support)), + Visa(resourceReference(R.string.common_contact_visa_support)), + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index 4ccf6cdd01..c659204216 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -7,17 +7,22 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.domain.common.TapWorkarounds.isVisa import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.CardInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.redux.LegacyAction import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsUM +import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -47,6 +52,7 @@ internal class DetailsModel @Inject constructor( private val appStateHolder: ReduxStateHolder, private val getCardInfoUseCase: GetCardInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val getWalletsUseCase: GetWalletsUseCase, override val dispatchers: CoroutineDispatcherProvider, ) : Model() { @@ -84,6 +90,7 @@ internal class DetailsModel @Inject constructor( socials = socialsBuilder.buildAll(), appVersion = getAppVersion(), ), + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, popBack = router::pop, ), ) @@ -99,12 +106,88 @@ internal class DetailsModel @Inject constructor( private fun sendFeedback() { modelScope.launch { + val userWallets = getWalletsUseCase.invokeSync() + val scanResponse = getSelectedWalletSyncUseCase().getOrNull()?.scanResponse ?: error("Selected wallet is null") val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch - sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(cardInfo = cardInfo)) + val feedbackType = when { + userWallets.all { it.scanResponse.card.isVisa } -> FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + userWallets.all { it.scanResponse.card.isVisa.not() } -> FeedbackEmailType.DirectUserRequest(cardInfo) + else -> { + showFeedbackEmailTypeOptionBS(cardInfo) + return@launch + } + } + + sendFeedbackEmailUseCase(feedbackType) + } + } + + private fun showFeedbackEmailTypeOptionBS(selectedCardInfo: CardInfo) { + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = { + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = + it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + ) + } + }, + content = SelectEmailFeedbackTypeBS( + onOptionClick = { option -> + onEmailFeedbackTypeOptionSelected( + selectedCardInfo = selectedCardInfo, + option = option, + ) + + state.update { + it.copy( + selectFeedbackEmailTypeBSConfig = + it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + ) + } + }, + ), + ), + ) + } + } + + private fun onEmailFeedbackTypeOptionSelected( + selectedCardInfo: CardInfo, + option: SelectEmailFeedbackTypeBS.Option, + ) { + modelScope.launch { + val feedbackType = when (option) { + SelectEmailFeedbackTypeBS.Option.General -> { + if (selectedCardInfo.isVisa.not()) { + FeedbackEmailType.DirectUserRequest(selectedCardInfo) + } else { + val scanResponse = getWalletsUseCase.invokeSync() + .firstOrNull { it.scanResponse.card.isVisa.not() }?.scanResponse ?: return@launch + val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch + FeedbackEmailType.DirectUserRequest(cardInfo) + } + } + SelectEmailFeedbackTypeBS.Option.Visa -> { + if (selectedCardInfo.isVisa) { + FeedbackEmailType.Visa.DirectUserRequest(selectedCardInfo) + } else { + val scanResponse = getWalletsUseCase.invokeSync() + .firstOrNull { it.scanResponse.card.isVisa }?.scanResponse ?: return@launch + val cardInfo = getCardInfoUseCase(scanResponse).getOrNull() ?: return@launch + FeedbackEmailType.Visa.DirectUserRequest(cardInfo) + } + } + } + + sendFeedbackEmailUseCase(feedbackType) } } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt index 0c2faf9735..cf172f3a90 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt @@ -59,6 +59,8 @@ internal fun DetailsScreen( userWalletListBlockContent = userWalletListBlockContent, ) } + + SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig) } @Composable diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt new file mode 100644 index 0000000000..583b911d76 --- /dev/null +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectFeedbackEmailTypeBottomSheet.kt @@ -0,0 +1,58 @@ +package com.tangem.features.details.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.inputrow.InputRowChecked +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS +import com.tangem.features.details.impl.R + +@Composable +internal fun SelectFeedbackEmailTypeBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + titleText = resourceReference(R.string.common_choose_action), + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: SelectEmailFeedbackTypeBS) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + SelectEmailFeedbackTypeBS.Option.entries.forEachIndexed { index, type -> + DividerContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = SelectEmailFeedbackTypeBS.Option.entries.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable { content.onOptionClick(type) }, + showDivider = index != SelectEmailFeedbackTypeBS.Option.entries.lastIndex, + ) { + InputRowChecked( + text = type.text, + checked = false, + ) + } + } + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/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/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt index eef9deef0d..23cde30f76 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletsFetcher.kt @@ -18,19 +18,23 @@ import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.lce import com.tangem.domain.core.utils.getOrElse import com.tangem.domain.core.utils.toLce +import com.tangem.domain.models.ArtworkModel import com.tangem.domain.tokens.GetWalletTotalBalanceUseCase import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.model.TotalFiatBalance import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.usecase.GetCardImageUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.details.impl.R +import com.tangem.operations.attestation.ArtworkSize import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import javax.inject.Inject +@Suppress("LongParameterList") @ModelScoped internal class UserWalletsFetcher @Inject constructor( getWalletsUseCase: GetWalletsUseCase, @@ -39,8 +43,11 @@ internal class UserWalletsFetcher @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val router: Router, private val messageSender: UiMessageSender, + private val getCardImageUseCase: GetCardImageUseCase, ) { + private var loadedArtworks: HashMap = hashMapOf() + @OptIn(ExperimentalCoroutinesApi::class) val userWallets: Flow> = getWalletsUseCase().transformLatest { wallets -> val uiModels = UserWalletItemUMConverter(onClick = ::navigateToWalletSettings).convertList(wallets) @@ -52,12 +59,14 @@ internal class UserWalletsFetcher @Inject constructor( flow = getSelectedAppCurrencyUseCase().distinctUntilChanged(), flow2 = getBalanceHidingSettingsUseCase().distinctUntilChanged(), flow3 = getWalletTotalBalanceUseCase(wallets.map(UserWallet::walletId)).distinctUntilChanged(), - ) { maybeAppCurrency, balanceHidingSettings, maybeBalances -> + flow4 = loadArtworks(wallets), + ) { maybeAppCurrency, balanceHidingSettings, maybeBalances, artworks -> val models = createUiModels( wallets = wallets, maybeAppCurrency = maybeAppCurrency, maybeBalances = maybeBalances, balanceHidingSettings = balanceHidingSettings, + artworks = artworks, ).getOrElse( ifLoading = { return@combine }, ifError = { @@ -72,11 +81,27 @@ internal class UserWalletsFetcher @Inject constructor( }.collect() } + private fun loadArtworks(wallets: List): Flow> { + return flow { + emit(hashMapOf()) // emits right away so the transform doesn't wait for the images' loading to finish + wallets.forEach { wallet -> + val artwork = getCardImageUseCase( + cardId = wallet.cardId, + cardPublicKey = wallet.scanResponse.card.cardPublicKey, + size = ArtworkSize.SMALL, + ) + loadedArtworks[wallet.walletId] = artwork + emit(loadedArtworks) + } + } + } + private fun createUiModels( wallets: List, maybeAppCurrency: Either, maybeBalances: Lce>, balanceHidingSettings: BalanceHidingSettings, + artworks: HashMap, ): Lce> = lce { val balances = withError( transform = { Error.UnableToGetBalances }, @@ -99,6 +124,7 @@ internal class UserWalletsFetcher @Inject constructor( appCurrency = appCurrency, balance = balance, isBalanceHidden = balanceHidingSettings.isBalanceHidden, + artwork = artworks[userWallet.walletId], ) .convert(userWallet) } diff --git a/features/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/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt index 16c72a0b96..4dd21ecb8a 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/portfolio/impl/ui/preview/PreviewAddToPortfolioBSContentProvider.kt @@ -27,7 +27,6 @@ internal class PreviewAddToPortfolioBSContentProvider : PreviewParameterProvider name = stringReference("Wallet 1"), information = stringReference("3 cards"), balance = UserWalletItemUM.Balance.Loading, - imageUrl = "", isEnabled = true, endIcon = UserWalletItemUM.EndIcon.Arrow, onClick = {}, diff --git a/features/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/NFTAssetTraitsComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTAssetTraitsComponent.kt new file mode 100644 index 0000000000..2be81fbcef --- /dev/null +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTAssetTraitsComponent.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.nft.models.NFTAsset + +interface NFTAssetTraitsComponent : ComposableContentComponent { + + data class Params( + 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/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/NFTDetailsBlockComponent.kt b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt new file mode 100644 index 0000000000..676f626597 --- /dev/null +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsBlockComponent.kt @@ -0,0 +1,17 @@ +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 NFTDetailsBlockComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val nftCollectionName: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/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..2848de0dd5 --- /dev/null +++ b/features/nft/api/src/main/kotlin/com/tangem/features/nft/component/NFTDetailsComponent.kt @@ -0,0 +1,17 @@ +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, + val nftCollectionName: String, + ) + + 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 deleted file mode 100644 index b456b29b0b..0000000000 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/NFTFeatureModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.features.nft - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -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 NFTFeatureModule { - - @Provides - @Singleton - fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): NFTFeatureToggles { - return DefaultNFTFeatureToggles(featureTogglesManager) - } -} \ 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..c5eec23071 --- /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, String) -> 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(name.orEmpty()) } + .toPersistentList(), + ) + } + + private fun NFTAsset.transform(collectionName: String): 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, collectionName) + }, + ) + + 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..b8b89b6d9b --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/model/NFTCollectionsModel.kt @@ -0,0 +1,149 @@ +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, collectionName: String) { + router.push( + AppRoute.NFTDetails( + userWalletId = params.userWalletId, + nftAsset = asset, + collectionName = collectionName, + ), + ) + } + + 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..e89b5bbe41 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/collections/ui/NFTCollection.kt @@ -0,0 +1,146 @@ +package com.tangem.features.nft.collections.ui + +import android.content.res.Configuration +import androidx.compose.animation.core.animateFloatAsState +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.rotate +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.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.common.ui.NFTLogo +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, + ) { + NFTLogo( + imageUrl = state.logoUrl, + networkIconId = state.networkIconId, + background = TangemTheme.colors.background.primary, + ) + + 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 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/common/ui/NFTLogo.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTLogo.kt new file mode 100644 index 0000000000..db5f6d01ca --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/ui/NFTLogo.kt @@ -0,0 +1,60 @@ +package com.tangem.features.nft.common.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.currency.icon.CurrencyIconTopBadge +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun NFTLogo(imageUrl: String?, @DrawableRes networkIconId: Int, background: Color = Color.Transparent) { + val networkBadgeOffset = TangemTheme.dimens.spacing6 + + Box( + modifier = Modifier, + ) { + SubcomposeAsyncImage( + modifier = Modifier + .align(Alignment.CenterStart) + .size(TangemTheme.dimens.size36) + .clip(TangemTheme.shapes.roundedCorners8), + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + loading = { + RectangleShimmer(radius = TangemTheme.dimens.radius8) + }, + error = { + Box( + modifier = Modifier + .clip(shape = TangemTheme.shapes.roundedCorners8) + .background(TangemTheme.colors.field.primary), + ) + }, + contentScale = ContentScale.Crop, + contentDescription = null, + ) + CurrencyIconTopBadge( + modifier = Modifier + .offset(x = networkBadgeOffset, y = -networkBadgeOffset) + .align(Alignment.TopEnd), + iconResId = networkIconId, + alpha = 1f, + colorFilter = null, + background = background, + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/DefaultNFTDetailsComponent.kt 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/block/DefaultNFTDetailsBlockComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt new file mode 100644 index 0000000000..1c20de2872 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.nft.details.block + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.nft.component.NFTDetailsBlockComponent +import com.tangem.features.nft.details.block.ui.NFTDetailsBlock +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +class DefaultNFTDetailsBlockComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NFTDetailsBlockComponent.Params, +) : NFTDetailsBlockComponent, AppComponentContext by context { + + @Composable + override fun Content(modifier: Modifier) { + NFTDetailsBlock( + assetName = stringReference(params.nftAsset.name.orEmpty()), + collectionName = stringReference(params.nftCollectionName), + assetImage = params.nftAsset.media?.url, + networkIconRes = getActiveIconRes(params.nftAsset.network.id.value), + ) + } + + @AssistedFactory + interface Factory : NFTDetailsBlockComponent.Factory { + override fun create( + context: AppComponentContext, + params: NFTDetailsBlockComponent.Params, + ): DefaultNFTDetailsBlockComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt new file mode 100644 index 0000000000..6336d2dbb2 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/ui/NFTDetailsBlock.kt @@ -0,0 +1,84 @@ +package com.tangem.features.nft.details.block.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.common.ui.NFTLogo +import com.tangem.features.nft.impl.R + +@Composable +internal fun NFTDetailsBlock( + assetName: TextReference, + collectionName: TextReference, + assetImage: String?, + networkIconRes: Int, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.action) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "NFT Asset", + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.secondary, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NFTLogo( + assetImage, + networkIconRes, + background = TangemTheme.colors.background.action, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = assetName.resolveReference(), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = collectionName.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun NFTDetailsBlock_Preview() { + TangemThemePreview { + NFTDetailsBlock( + assetName = stringReference("NFT Asset Name"), + collectionName = stringReference("NFT Collection"), + assetImage = null, + networkIconRes = R.drawable.img_polygon_22, + ) + } +} +// endregion \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTAssetUM.kt 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..67955452f9 --- /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 onSeeAllTraitsClick: () -> Unit, + val onExploreClick: () -> Unit, + val onSendClick: () -> Unit, + val bottomSheetConfig: TangemBottomSheetConfig?, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTInfoBottomSheetConfig.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTInfoBottomSheetConfig.kt new file mode 100644 index 0000000000..3cb7f5273c --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/entity/NFTInfoBottomSheetConfig.kt @@ -0,0 +1,9 @@ +package com.tangem.features.nft.details.entity + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference + +internal data class NFTInfoBottomSheetConfig( + val title: TextReference, + val text: TextReference, +) : TangemBottomSheetConfigContent \ 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..446c289c09 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/model/NFTDetailsModel.kt @@ -0,0 +1,77 @@ +package com.tangem.features.nft.details.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.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.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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, + onSeeAllTraitsClick = ::onSeeAllTraitsClick, + onExploreClick = ::onExploreClick, + onSendClick = ::onSendClick, + bottomSheetConfig = null, + ), + ) + + private fun onReadMoreClick() { + // TODO implement + } + + private fun onSeeAllTraitsClick() { + router.push( + AppRoute.NFTAssetTraits( + nftAsset = params.nftAsset, + ), + ) + } + + private fun onExploreClick() { + // TODO implement + } + + private fun onSendClick() { + // TODO implement + // router.push( + // AppRoute.NFTSend( + // userWalletId = params.userWalletId, + // nftAsset = params.nftAsset, + // nftCollectionName = params.nftCollectionName, + // ), + // ) + } + + 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..1fba75b717 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/NFTDetails.kt @@ -0,0 +1,69 @@ +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.components.bottomsheets.TangemBottomSheetConfig +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.details.entity.NFTInfoBottomSheetConfig +import com.tangem.features.nft.details.ui.bottomsheet.NFTInfoBottomSheet +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, + onSeeAllTraitsClick = state.onSeeAllTraitsClick, + onExploreClick = state.onExploreClick, + modifier = Modifier + .padding(innerPadding), + ) + ShowBottomSheet(state.bottomSheetConfig) + }, + floatingActionButtonPosition = FabPosition.Center, + floatingActionButton = { + PrimaryButton( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + text = stringResourceSafe(id = R.string.common_send), + onClick = state.onSendClick, + ) + }, + ) +} + +@Composable +fun ShowBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) { + if (bottomSheetConfig == null) return + when (bottomSheetConfig.content) { + is NFTInfoBottomSheetConfig -> NFTInfoBottomSheet(bottomSheetConfig) + } +} \ 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..b077835ec8 --- /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, + onSeeAllTraitsClick: () -> 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 = onSeeAllTraitsClick, + ) + }, + ) + 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 = { }, + onSeeAllTraitsClick = { }, + 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/details/ui/bottomsheet/NFTInfoBottomSheet.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/bottomsheet/NFTInfoBottomSheet.kt new file mode 100644 index 0000000000..979a5e989f --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/ui/bottomsheet/NFTInfoBottomSheet.kt @@ -0,0 +1,73 @@ +package com.tangem.features.nft.details.ui.bottomsheet + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +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 +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.details.entity.NFTInfoBottomSheetConfig + +@Composable +fun NFTInfoBottomSheet(config: TangemBottomSheetConfig) { + val scrollState = rememberScrollState() + + TangemBottomSheet( + config = config, + title = { content -> + TangemBottomSheetTitle(title = content.title) + }, + ) { content -> + Column( + modifier = Modifier.verticalScroll(scrollState), + ) { + Text( + text = content.text.resolveReference(), + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2, + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun Preview_StakingInfoBottomSheet() { + TangemThemePreview { + NFTInfoBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = NFTInfoBottomSheetConfig( + title = stringReference("Title"), + text = stringReference( + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce varius neque vel ligula + tincidunt, nec faucibus nulla ultricies. Maecenas euismod arcu in nunc volutpat, + at bibendum eros lacinia. Proin hendrerit massa non velit congue, + in volutpat nisi consequat. Sed vitae justo nec orci tincidunt malesuada. + Nullam feugiat purus vel lectus efficitur, vel fringilla urna volutpat. + Donec sagittis enim in metus lacinia, vel tempor nunc bibendum. + """.trimIndent(), + ), + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/di/NFTFeatureModule.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/di/NFTFeatureModule.kt new file mode 100644 index 0000000000..af79399a92 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/di/NFTFeatureModule.kt @@ -0,0 +1,87 @@ +package com.tangem.features.nft.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.core.decompose.model.Model +import com.tangem.features.nft.DefaultNFTFeatureToggles +import com.tangem.features.nft.NFTFeatureToggles +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.NFTDetailsBlockComponent +import com.tangem.features.nft.component.NFTDetailsComponent +import com.tangem.features.nft.component.NFTReceiveComponent +import com.tangem.features.nft.component.NFTAssetTraitsComponent +import com.tangem.features.nft.details.DefaultNFTDetailsComponent +import com.tangem.features.nft.details.block.DefaultNFTDetailsBlockComponent +import com.tangem.features.nft.details.model.NFTDetailsModel +import com.tangem.features.nft.receive.DefaultNFTReceiveComponent +import com.tangem.features.nft.receive.model.NFTReceiveModel +import com.tangem.features.nft.traits.DefaultNFTAssetTraitsComponent +import com.tangem.features.nft.traits.model.NFTAssetTraitsModel +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 +@InstallIn(SingletonComponent::class) +internal object NFTFeatureModule { + + @Provides + @Singleton + 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 + + @Binds + @Singleton + fun bindNFTDetailsBlockComponentFactory( + impl: DefaultNFTDetailsBlockComponent.Factory, + ): NFTDetailsBlockComponent.Factory + + @Binds + @Singleton + fun bindNFTTraitsComponentFactory(impl: DefaultNFTAssetTraitsComponent.Factory): NFTAssetTraitsComponent.Factory + + @Binds + @IntoMap + @ClassKey(NFTAssetTraitsModel::class) + fun bindNFTTraitsModel(model: NFTAssetTraitsModel): Model +} \ 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/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/DefaultNFTAssetTraitsComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/DefaultNFTAssetTraitsComponent.kt new file mode 100644 index 0000000000..ce9f6af262 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/DefaultNFTAssetTraitsComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.nft.traits + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.nft.component.NFTAssetTraitsComponent +import com.tangem.features.nft.traits.ui.NFTAssetTraits +import com.tangem.features.nft.traits.model.NFTAssetTraitsModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNFTAssetTraitsComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: NFTAssetTraitsComponent.Params, +) : NFTAssetTraitsComponent, AppComponentContext by context { + + private val model: NFTAssetTraitsModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + + NFTAssetTraits(state) + } + + @AssistedFactory + interface Factory : NFTAssetTraitsComponent.Factory { + override fun create( + context: AppComponentContext, + params: NFTAssetTraitsComponent.Params, + ): DefaultNFTAssetTraitsComponent + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt new file mode 100644 index 0000000000..1837fa3f30 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitUM.kt @@ -0,0 +1,7 @@ +package com.tangem.features.nft.traits.entity + +data class NFTAssetTraitUM( + val id: String, + val name: String, + val value: String, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt new file mode 100644 index 0000000000..11b3fd9eea --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/entity/NFTAssetTraitsUM.kt @@ -0,0 +1,8 @@ +package com.tangem.features.nft.traits.entity + +import kotlinx.collections.immutable.ImmutableList + +data class NFTAssetTraitsUM( + val onBackClick: () -> Unit, + val traits: ImmutableList, +) \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt new file mode 100644 index 0000000000..73c030f059 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/model/NFTAssetTraitsModel.kt @@ -0,0 +1,48 @@ +package com.tangem.features.nft.traits.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.features.nft.component.NFTAssetTraitsComponent +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@ModelScoped +internal class NFTAssetTraitsModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + paramsContainer: ParamsContainer, +) : Model() { + + private val params: NFTAssetTraitsComponent.Params = paramsContainer.require() + + val state: StateFlow get() = _state + + private val _state = MutableStateFlow( + value = NFTAssetTraitsUM( + traits = params.nftAsset.transform(), + onBackClick = ::navigateBack, + ), + ) + + private fun NFTAsset.transform(): ImmutableList = this + .traits + .mapIndexed { index, trait -> + NFTAssetTraitUM( + id = index.toString(), + name = trait.name, + value = trait.value, + ) + }.toPersistentList() + + private fun navigateBack() { + router.pop() + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt new file mode 100644 index 0000000000..dc9135f37c --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTrait.kt @@ -0,0 +1,29 @@ +package com.tangem.features.nft.traits.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM + +@Composable +internal fun NFTAssetTrait(state: NFTAssetTraitUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + ) { + Text( + text = state.name, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing4), + text = state.value, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + } +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt new file mode 100644 index 0000000000..f6e27276f2 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraits.kt @@ -0,0 +1,41 @@ +package com.tangem.features.nft.traits.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.nft.impl.R +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM + +@Composable +internal fun NFTAssetTraits(state: NFTAssetTraitsUM, modifier: Modifier = Modifier) { + BackHandler(onBack = state.onBackClick) + + Scaffold( + modifier = modifier, + containerColor = TangemTheme.colors.background.secondary, + topBar = { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + startButton = TopAppBarButtonUM( + iconRes = R.drawable.ic_back_24, + onIconClicked = state.onBackClick, + ), + title = stringResourceSafe(R.string.nft_traits_title), + ) + }, + content = { innerPadding -> + NFTAssetTraitsContent( + modifier = Modifier + .padding(innerPadding), + state = state, + ) + }, + ) +} \ No newline at end of file diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt new file mode 100644 index 0000000000..c51f428b21 --- /dev/null +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/traits/ui/NFTAssetTraitsContent.kt @@ -0,0 +1,94 @@ +package com.tangem.features.nft.traits.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.block.information.InformationBlock +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.nft.traits.entity.NFTAssetTraitUM +import com.tangem.features.nft.traits.entity.NFTAssetTraitsUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun NFTAssetTraitsContent(state: NFTAssetTraitsUM, modifier: Modifier = Modifier) { + InformationBlock( + modifier = modifier + .padding(TangemTheme.dimens.spacing16), + title = null, + contentHorizontalPadding = 0.dp, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()), + ) { + state.traits.forEach { trait -> + key(trait.id) { + NFTAssetTrait( + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing12, + vertical = TangemTheme.dimens.spacing8, + ), + state = trait, + ) + } + } + } + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_NFTDetailsAssetAsset(@PreviewParameter(NFTAssetTraitsProvider::class) state: NFTAssetTraitsUM) { + TangemThemePreview { + NFTAssetTraitsContent( + state = state, + ) + } +} + +private class NFTAssetTraitsProvider : CollectionPreviewParameterProvider( + collection = listOf( + NFTAssetTraitsUM( + traits = persistentListOf( + NFTAssetTraitUM( + id = "1", + name = "Trait 1", + value = "Value", + ), + NFTAssetTraitUM( + id = "2", + name = "Trait 2", + value = "Value", + ), + NFTAssetTraitUM( + id = "3", + name = "Trait 3", + value = "Value", + ), + NFTAssetTraitUM( + id = "4", + name = "Trait 4", + value = "Value", + ), + NFTAssetTraitUM( + id = "5", + name = "Trait 5", + value = "Value", + ), + ), + onBackClick = { }, + ), + ), +) \ No newline at end of file diff --git a/features/onboarding-v2/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/common/ui/WalletCard.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt index 79f9406a97..05e2c4fd39 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/common/ui/WalletCard.kt @@ -7,14 +7,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import coil.compose.AsyncImage import coil.request.ImageRequest +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.features.onboarding.v2.impl.R @Composable -internal fun WalletCard(url: String?, modifier: Modifier = Modifier) { +internal fun WalletCard(artwork: ArtworkUM?, modifier: Modifier = Modifier) { AsyncImage( modifier = modifier, model = ImageRequest.Builder(LocalContext.current) - .data(url) + .data(artwork?.verifiedArtwork ?: artwork?.defaultUrl) .crossfade(true) .build(), placeholder = painterResource(R.drawable.card_placeholder_black), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/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..b6a675a820 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.replaceAll import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.models.scan.ProductType import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -12,18 +13,23 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.common.util.cardTypesResolver -import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.legacy.UserWalletsListManager +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.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.NonCancellable @@ -43,6 +49,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 +63,7 @@ internal class OnboardingEntryModel @Inject constructor( } val startRoute = routeByProductType(params.scanResponse) + val onboardingTwinModelCallbacks = OnboardingTwinModelCallbacks() fun onManageTokensDone() { navigateToFinalScreenFlow() @@ -66,32 +74,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 +104,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 +142,125 @@ 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()) { + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.BiometricScreenOpened) + } stackNavigation.replaceAll( - OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks()), + OnboardingRoute.AskBiometry(modelCallbacks = AskBiometryModelCallbacks(doneMode)), ) } else { - stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen)) + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } + stackNavigation.replaceAll( + OnboardingRoute.Done( + mode = doneMode, + onDone = ::exitComponentScreen, + ), + ) } } } else { - stackNavigation.replaceAll(OnboardingRoute.Done(onDone = ::navigateToWalletScreen)) + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } + 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)) + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } + 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)) + doIfVisa { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.SuccessScreenOpened) + } + 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) } } } + + private inline fun doIfVisa(function: () -> Unit) { + if (params.scanResponse.productType == ProductType.Visa) { + function() + } + } + + 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..c685d24d3c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -5,34 +5,42 @@ 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.core.ui.components.artwork.ArtworkUM import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ProductType import com.tangem.domain.onboarding.repository.OnboardingRepository 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.attestation.ArtworkSize 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,37 +64,42 @@ 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() { modelScope.launch { backups.collectLatest { backup -> when { - _uiState.value.artwork2Url == null && backup.card2 != null -> { + _uiState.value.artwork2 == null && backup.card2 != null -> { val artwork = - getCardImageUseCase.invoke(backup.card2.cardId, backup.card2.cardPublicKey) + getCardImageUseCase.invoke( + cardId = backup.card2.cardId, + cardPublicKey = backup.card2.cardPublicKey, + size = ArtworkSize.LARGE, + ) _uiState.update { - it.copy(artwork2Url = artwork) + it.copy(artwork2 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } - _uiState.value.artwork3Url == null && backup.card3 != null -> { + _uiState.value.artwork3 == null && backup.card3 != null -> { val artwork = - getCardImageUseCase.invoke(backup.card3.cardId, backup.card3.cardPublicKey) + getCardImageUseCase.invoke( + cardId = backup.card3.cardId, + cardPublicKey = backup.card3.cardPublicKey, + size = ArtworkSize.LARGE, + ) _uiState.update { - it.copy(artwork3Url = artwork) + it.copy(artwork3 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } } @@ -153,12 +166,14 @@ internal class OnboardingMultiWalletModel @Inject constructor( private fun loadCardArtwork() { modelScope.launch { val artwork = - getCardImageUseCase.invoke(params.scanResponse.card.cardId, params.scanResponse.card.cardPublicKey) + getCardImageUseCase.invoke( + cardId = params.scanResponse.card.cardId, + cardPublicKey = params.scanResponse.card.cardPublicKey, + size = ArtworkSize.LARGE, + ) - if (artwork != getCardImageUseCase.getDefaultFallbackUrl()) { - _uiState.update { - it.copy(artwork1Url = artwork) - } + _uiState.update { + it.copy(artwork1 = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt index 62bc6aaf7a..00b794fd58 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/OnboardingMultiWallet.kt @@ -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,31 +20,14 @@ 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(), ) { if (isSeedPhraseState.not()) { WalletArtworks( - url1 = state.artwork1Url, - url2 = state.artwork2Url, - url3 = state.artwork3Url, + artwork1 = state.artwork1, + artwork2 = state.artwork2, + artwork3 = state.artwork3, modifier = Modifier .padding(horizontal = 34.dp) .padding(top = 20.dp) @@ -73,7 +53,7 @@ internal fun OnboardingMultiWallet( private fun Preview() { TangemThemePreview { OnboardingMultiWallet( - state = OnboardingMultiWalletUM(artwork1Url = null), + state = OnboardingMultiWalletUM(artwork1 = null), artworksState = WalletArtworksState.Folded, isSeedPhraseState = false, childContent = { md -> @@ -93,7 +73,7 @@ private fun Preview() { private fun Preview2() { TangemThemePreview { OnboardingMultiWallet( - state = OnboardingMultiWalletUM(artwork1Url = null), + state = OnboardingMultiWalletUM(artwork1 = null), artworksState = WalletArtworksState.Folded, isSeedPhraseState = true, childContent = { md -> diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt index cb4b29a905..d199e807dc 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/WalletArtwork.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import androidx.compose.ui.zIndex +import com.tangem.core.ui.components.artwork.ArtworkUM import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.common.ui.WalletCard @@ -57,9 +58,9 @@ sealed class WalletArtworksState { @Composable fun WalletArtworks( - url1: String?, - url2: String?, - url3: String?, + artwork1: ArtworkUM?, + artwork2: ArtworkUM?, + artwork3: ArtworkUM?, state: WalletArtworksState, modifier: Modifier = Modifier, ) { @@ -120,9 +121,9 @@ fun WalletArtworks( } AnimatedWalletCards( - url1 = url1, - url2 = url2, - url3 = url3, + artwork1 = artwork1, + artwork2 = artwork2, + artwork3 = artwork3, modifier = Modifier .widthIn(max = 450.dp) .matchParentSize(), @@ -152,9 +153,9 @@ private data class WalletCardTransitionState( @Suppress("LongParameterList") @Composable private fun AnimatedWalletCards( - url1: String?, - url2: String?, - url3: String?, + artwork1: ArtworkUM?, + artwork2: ArtworkUM?, + artwork3: ArtworkUM?, transition1: Transition, transition2: Transition, transition3: Transition, @@ -199,7 +200,7 @@ private fun AnimatedWalletCards( scaleY = scaleY3 rotationZ = rotation3 }, - url = url3, + artwork = artwork3, ) WalletCard( @@ -217,7 +218,7 @@ private fun AnimatedWalletCards( scaleX = scaleX2 scaleY = scaleY2 }, - url = url2, + artwork = artwork2, ) WalletCard( @@ -234,7 +235,7 @@ private fun AnimatedWalletCards( translationY = translationY1 rotationZ = rotation1 }, - url = url1, + artwork = artwork1, ) } } @@ -447,9 +448,9 @@ private fun Preview() { var state: WalletArtworksState by remember { mutableStateOf(WalletArtworksState.Folded) } WalletArtworks( - url1 = null, - url2 = null, - url3 = null, + artwork1 = null, + artwork2 = null, + artwork3 = null, state = state, modifier = Modifier.fillMaxWidth(), ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt index 5aa205dc81..3220607f0f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/ui/state/OnboardingMultiWalletUM.kt @@ -1,10 +1,9 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.ui.state -import com.tangem.features.onboarding.v2.multiwallet.impl.common.ui.OnboardingDialogUM +import com.tangem.core.ui.components.artwork.ArtworkUM internal data class OnboardingMultiWalletUM( - val artwork1Url: String? = null, - val artwork2Url: String? = null, - val artwork3Url: String? = null, - val dialog: OnboardingDialogUM? = null, + val artwork1: ArtworkUM? = null, + val artwork2: ArtworkUM? = null, + val artwork3: ArtworkUM? = null, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/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..ce79faeb51 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/model/OnboardingNoteCreateWalletModel.kt @@ -1,26 +1,106 @@ 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.core.ui.components.artwork.ArtworkUM +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.wallets.builder.UserWalletBuilder +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 { state -> + _uiState.value = _uiState.value.copy( + artwork = state.cardArtwork?.let { + ArtworkUM(it.verifiedArtwork, it.defaultUrl) + }, + ) + } + } + } } \ 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 86% 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..91644d77bb 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 @@ -30,24 +32,24 @@ internal fun OnboardingNoteCreateWallet(state: OnboardingNoteCreateWalletUM, mod verticalArrangement = Arrangement.Bottom, ) { WalletArtworks( - url1 = state.artworkUrl, - url2 = null, - url3 = null, + artwork1 = state.artwork, + artwork2 = null, + artwork3 = null, modifier = Modifier - .padding(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( @@ -83,7 +86,7 @@ private fun OnboardingNoteCreatePreview() { TangemThemePreview { OnboardingNoteCreateWallet( state = OnboardingNoteCreateWalletUM( - artworkUrl = null, + artwork = null, createWalletInProgress = false, onCreateClick = {}, ), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt index 950445d00d..d11347b973 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/create/ui/state/OnboardingNoteCreateWalletUM.kt @@ -1,7 +1,9 @@ package com.tangem.features.onboarding.v2.note.impl.child.create.ui.state +import com.tangem.core.ui.components.artwork.ArtworkUM + data class OnboardingNoteCreateWalletUM( - val artworkUrl: String? = null, + val artwork: ArtworkUM? = null, val createWalletInProgress: Boolean = false, val onCreateClick: () -> Unit = {}, ) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt index f2a8e3bf60..bdac9d90ca 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/model/OnboardingNoteTopUpModel.kt @@ -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( + cardArtwork = it.cardArtwork, + ) + } + } + } + + 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..9830905a93 --- /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,103 @@ +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.components.artwork.ArtworkUM +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 +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun OnboardingNoteTopUpHeader( + balance: String, + cardArtwork: ArtworkUM?, + 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), + artwork = cardArtwork, + ) + RefreshButton( + modifier = Modifier.align(Alignment.BottomCenter), + isRefreshing = isRefreshing, + onRefreshBalanceClick = onRefreshBalanceClick, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun OnboardinNoteTopUpHeaderPreview() { + TangemThemePreview { + OnboardingNoteTopUpHeader( + balance = "0.00000001 BTC", + cardArtwork = ArtworkUM(null as ImmutableList?, ""), + 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 64% 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..476d06b378 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, + cardArtwork = state.cardArtwork, 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..c9d9ca3541 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/child/topup/ui/state/OnboardingNoteTopUpUM.kt @@ -1,12 +1,19 @@ package com.tangem.features.onboarding.v2.note.impl.child.topup.ui.state +import com.tangem.core.ui.components.artwork.ArtworkUM +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig + data class OnboardingNoteTopUpUM( - val cardArtworkUrl: String? = null, - val availableForBuy: Boolean = true, - val balance: String? = null, + val cardArtwork: ArtworkUM? = 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..740f471257 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteCommonState.kt @@ -0,0 +1,13 @@ +package com.tangem.features.onboarding.v2.note.impl.model + +import com.tangem.core.ui.components.artwork.ArtworkUM +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet + +internal data class OnboardingNoteCommonState( + val scanResponse: ScanResponse, + val userWallet: UserWallet? = null, + val cardArtwork: ArtworkUM? = null, + val cryptoCurrencyStatus: CryptoCurrencyStatus? = null, +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt index 87a00a91e1..43303a39b2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/model/OnboardingNoteModel.kt @@ -4,46 +4,56 @@ 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.core.ui.components.artwork.ArtworkUM +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.GetCardImageUseCase +import com.tangem.features.onboarding.v2.common.ui.exitOnboardingDialog import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent import com.tangem.features.onboarding.v2.note.impl.OnboardingNoteInnerNavigationState import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute import com.tangem.features.onboarding.v2.note.impl.route.stepNum +import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.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 { + loadArtwork() + } fun updateStepForNewRoute(route: OnboardingNoteRoute) { _innerNavigationState.value = innerNavigationState.value.copy( @@ -53,6 +63,41 @@ 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 loadArtwork() { + modelScope.launch { + val cardInfo = params.scanResponse.card + val artwork = getCardImageUseCase.invoke( + cardId = cardInfo.cardId, + cardPublicKey = cardInfo.cardPublicKey, + size = ArtworkSize.LARGE, + ) + _uiState.update { + it.copy(cardArtwork = ArtworkUM(artwork.verifiedArtwork, artwork.defaultUrl)) + } + } } } \ 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/stepper/impl/DefaultOnboardingStepperComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt index 640ac88a7f..5bedacb9ee 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/DefaultOnboardingStepperComponent.kt @@ -9,6 +9,7 @@ import com.arkivanov.essenty.instancekeeper.getOrCreateSimple import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.common.TapWorkarounds.isVisa import com.tangem.domain.feedback.GetCardInfoUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType @@ -40,7 +41,13 @@ internal class DefaultOnboardingStepperComponent @AssistedInject constructor( componentScope.launch { val cardInfo = getCardInfoUseCase(params.scanResponse).getOrNull() ?: return@launch - sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(cardInfo)) + sendFeedbackEmailUseCase( + if (params.scanResponse.card.isVisa) { + FeedbackEmailType.Visa.Activation(cardInfo) + } else { + FeedbackEmailType.DirectUserRequest(cardInfo) + }, + ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/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..97758be4e1 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/ui/TwinWalletArtwork.kt @@ -0,0 +1,380 @@ +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.components.artwork.ArtworkUM +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 + }, + artwork = ArtworkUM(defaultUrl = 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 + }, + artwork = ArtworkUM(defaultUrl = 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..c18f5585e7 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt @@ -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,9 @@ 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, pinCodeValidationError = false), + ) }, ), ) @@ -170,11 +178,12 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( config = OnboardingVisaPinCodeComponent.Config( scanResponse = model.currentScanResponse.value, activationOrderInfo = route.activationOrderInfo, + wasValidationError = route.pinCodeValidationError, ), params = OnboardingVisaPinCodeComponent.Params( childParams = childParams, onDone = { - model.stackNavigation.push( + model.stackNavigation.pushNew( OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode), ) }, @@ -190,7 +199,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..a07b1acd3a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/accesscode/model/OnboardingVisaAccessCodeModel.kt @@ -4,9 +4,15 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +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 @@ -14,6 +20,9 @@ import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.domain.visa.repository.VisaAuthRepository import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.ui.state.OnboardingVisaAccessCodeUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.common.ActivationReadyEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.visa.VisaCardActivationTaskMode @@ -25,6 +34,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class OnboardingVisaAccessCodeModel @Inject constructor( @@ -34,6 +44,8 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( @Suppress("UnusedPrivateMember") private val tangemSdkManager: TangemSdkManager, private val visaAuthRepository: VisaAuthRepository, + private val uiMessageSender: UiMessageSender, + private val analyticsEventsHandler: AnalyticsEventHandler, ) : Model() { private val params: OnboardingVisaAccessCodeComponent.Config = paramsContainer.require() @@ -53,6 +65,10 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( val onBack = MutableSharedFlow() val onDone = MutableSharedFlow() + init { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.SettingAccessCodeStarted) + } + fun onBack() { if (uiState.value.buttonLoading) return @@ -76,6 +92,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( _uiState.update { it.copy( accessCodeFirst = textFieldValue, + accessCodeSecond = TextFieldValue(), atLeastMinCharsError = false, ) } @@ -93,11 +110,14 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( private fun onContinue() { when (uiState.value.step) { OnboardingVisaAccessCodeUM.Step.Enter -> { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.AccessCodeEntered) if (checkAccessCodeMinChars().not()) return _uiState.update { it.copy(step = OnboardingVisaAccessCodeUM.Step.ReEnter) } + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.AccessCodeReenterScreen) } OnboardingVisaAccessCodeUM.Step.ReEnter -> { if (checkAccessCodesMatch().not()) return + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.OnboardingVisa) startActivationProcess(accessCode = uiState.value.accessCodeFirst.text) } } @@ -138,7 +158,7 @@ internal class OnboardingVisaAccessCodeModel @Inject constructor( ) }.getOrElse { loading(false) - // TODO show alert + uiMessageSender.showErrorDialog(VisaAuthorizationAPIError) return@launch } @@ -148,17 +168,25 @@ 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) + analyticsEventsHandler.send( + VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE), + ) + 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..9f14644a5c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/approve/model/OnboardingVisaApproveModel.kt @@ -3,14 +3,22 @@ package com.tangem.features.onboarding.v2.visa.impl.child.approve.model import androidx.compose.runtime.Stable import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +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 import com.tangem.features.onboarding.v2.visa.impl.child.approve.OnboardingVisaApproveComponent import com.tangem.features.onboarding.v2.visa.impl.child.approve.ui.state.OnboardingVisaApproveUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.sdk.api.TangemSdkManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableSharedFlow @@ -27,6 +35,8 @@ internal class OnboardingVisaApproveModel @Inject constructor( visaActivationRepositoryFactory: VisaActivationRepository.Factory, override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, + private val uiMessageSender: UiMessageSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -42,6 +52,10 @@ internal class OnboardingVisaApproveModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.WalletPrepare) + } + private fun getInitialState(): OnboardingVisaApproveUM { return OnboardingVisaApproveUM( onApproveClick = ::onApproveClick, @@ -51,12 +65,14 @@ internal class OnboardingVisaApproveModel @Inject constructor( private fun onApproveClick() { loading(true) + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonApprove) + modelScope.launch { val dataToSign = runCatching { visaActivationRepository.getCustomerWalletAcceptanceData(params.preparationDataForApprove.request) }.getOrElse { loading(false) - // TODO show dialog + uiMessageSender.showErrorDialog(VisaAPIError) return@launch } @@ -66,17 +82,25 @@ 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) + analyticsEventHandler.send( + VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE), + ) + 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..b9d427dc44 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/choosewallet/model/OnboardingVisaChooseWalletModel.kt @@ -1,13 +1,15 @@ package com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model -import com.tangem.core.ui.extensions.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 import com.tangem.features.onboarding.v2.visa.impl.child.choosewallet.ui.state.SelectableChainRowUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableSharedFlow @@ -21,6 +23,7 @@ import javax.inject.Inject @ModelScoped internal class OnboardingVisaChooseWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val _uiState = MutableStateFlow(getInitialState()) @@ -32,7 +35,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 +44,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, @@ -53,7 +56,18 @@ internal class OnboardingVisaChooseWalletModel @Inject constructor( private fun onContinueClicked() { modelScope.launch { val selectedOption = _uiState.value.selectedOption ?: return@launch + val value = getAnalyticsValue(selectedOption.event) + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ChooseWalletScreen(value)) onEvent.emit(selectedOption.event) } } + + private fun getAnalyticsValue(event: Event) = when (event) { + Event.TangemWallet -> { + "Tangem" + } + Event.OtherWallet -> { + "Other" + } + } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/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..a138341670 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/inprogress/model/OnboardingVisaInProgressModel.kt @@ -2,21 +2,26 @@ package com.tangem.features.onboarding.v2.visa.impl.child.inprogress.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +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.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.route.OnboardingVisaRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.delay @@ -35,8 +40,10 @@ 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, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -49,6 +56,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( val onDone = MutableSharedFlow() init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ActivationInProgressScreen) modelScope.launch { while (true) { val result = runCatching { @@ -59,7 +67,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, @@ -67,9 +75,10 @@ internal class OnboardingVisaInProgressModel @Inject constructor( VisaActivationRemoteState.WaitingForActivationFinishing, -> { } - is VisaActivationRemoteState.WaitingPinCode -> { - navigateToPinCode(result.activationOrderInfo) - return@launch + is VisaActivationRemoteState.AwaitingPinCode -> { + navigateToPinCodeIfNeeded(result) { + return@launch + } } VisaActivationRemoteState.Activated -> { finishActivation() @@ -82,8 +91,37 @@ internal class OnboardingVisaInProgressModel @Inject constructor( } } - private suspend fun navigateToPinCode(activationOrderInfo: VisaActivationOrderInfo) { - onDone.emit(Params.DoneEvent.NavigateTo(OnboardingVisaRoute.PinCode(activationOrderInfo))) + private suspend inline fun navigateToPinCodeIfNeeded( + remoteState: VisaActivationRemoteState.AwaitingPinCode, + returnBlock: () -> Unit, + ) { + when (remoteState.status) { + VisaActivationRemoteState.AwaitingPinCode.Status.WaitingForPinCode -> { + onDone.emit( + Params.DoneEvent.NavigateTo( + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = false, + ), + ), + ) + returnBlock() + } + VisaActivationRemoteState.AwaitingPinCode.Status.WasError -> { + onDone.emit( + Params.DoneEvent.NavigateTo( + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = true, + ), + ), + ) + returnBlock() + } + VisaActivationRemoteState.AwaitingPinCode.Status.InProgress -> { + /** waiting for new state */ + } + } } private suspend fun finishActivation() { @@ -93,7 +131,7 @@ internal class OnboardingVisaInProgressModel @Inject constructor( val newTokens = runCatching { visaAuthRepository.refreshAccessTokens(authTokens.refreshToken) }.getOrElse { - // TODO show alert + uiMessageSender.showErrorDialog(VisaAuthorizationAPIError) return } @@ -110,9 +148,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..fa1adb5d3c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/otherwallet/model/OnboardingVisaOtherWalletModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -13,6 +14,7 @@ import com.tangem.domain.visa.model.VisaCardId import com.tangem.domain.visa.repository.VisaActivationRepository import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.OnboardingVisaOtherWalletComponent import com.tangem.features.onboarding.v2.visa.impl.child.otherwallet.ui.state.OnboardingVisaOtherWalletUM +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,6 +31,7 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val urlOpener: UrlOpener, private val shareManager: ShareManager, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val config = paramsContainer.require() @@ -44,13 +47,14 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( val onDone = MutableSharedFlow() init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.GoToWebsiteOpened) modelScope.launch { while (true) { val result = runCatching { visaActivationRepository.getActivationRemoteStateLongPoll() }.getOrNull() - if (result is VisaActivationRemoteState.WaitingPinCode) { + if (result is VisaActivationRemoteState.AwaitingPinCode) { onDone.emit(result.activationOrderInfo) break } @@ -68,10 +72,12 @@ internal class OnboardingVisaOtherWalletModel @Inject constructor( } private fun onShareClicked() { - shareManager.shareText("https://tangem.com/") + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonShareLink) + shareManager.shareText("https://tangem.com/") // TODO } private fun onOpenInBrowserClicked() { - urlOpener.openUrl("https://tangem.com/") + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ButtonBrowser) + urlOpener.openUrl("https://tangem.com/") // TODO } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/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/OnboardingVisaPinCodeComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt index b70d2995a1..baf52ede33 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/OnboardingVisaPinCodeComponent.kt @@ -48,6 +48,7 @@ internal class OnboardingVisaPinCodeComponent( data class Config( val scanResponse: ScanResponse, val activationOrderInfo: VisaActivationOrderInfo, + val wasValidationError: Boolean, ) data class Params( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt index 0b02574d50..432a024889 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/pincode/model/OnboardingVisaPinCodeModel.kt @@ -2,9 +2,11 @@ package com.tangem.features.onboarding.v2.visa.impl.child.pincode.model import androidx.compose.runtime.Stable import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +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,9 +15,10 @@ 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.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,11 +34,12 @@ 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, private val setVisaPinCodeUseCase: SetVisaPinCodeUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -48,33 +52,52 @@ internal class OnboardingVisaPinCodeModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.PinCodeScreenOpened) + } + private fun getInitialState(): OnboardingVisaPinCodeUM { return OnboardingVisaPinCodeUM( submitButtonEnabled = false, onPinCodeChange = ::onPinCodeChange, onSubmitClick = ::onSubmitClick, + error = if (params.wasValidationError) { + resourceReference(R.string.visa_onboarding_pin_not_accepted) + } else { + null + }, ) } 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) { + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.ErrorPinValidation) + resourceReference(R.string.visa_onboarding_pin_validation_error_message) + } else { + null + }, ) } } } private fun onSubmitClick() { - if (checkPinCode(_uiState.value.pinCode).not()) return + analyticsEventHandler.send(OnboardingVisaAnalyticsEvent.PinEntered) + 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 +111,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 +130,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..35869da702 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/OnboardingVisaWelcomeModel.kt @@ -2,14 +2,22 @@ package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model import com.tangem.common.CompletionResult import com.tangem.common.extensions.toHexString +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +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 import com.tangem.features.onboarding.v2.visa.impl.child.welcome.OnboardingVisaWelcomeComponent.Config import com.tangem.features.onboarding.v2.visa.impl.child.welcome.OnboardingVisaWelcomeComponent.DoneEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.ONBOARDING_SOURCE +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.VisaAnalyticsEvent import com.tangem.features.onboarding.v2.visa.impl.child.welcome.ui.state.OnboardingVisaWelcomeUM import com.tangem.features.onboarding.v2.visa.impl.common.ActivationReadyEvent import com.tangem.sdk.api.TangemSdkManager @@ -28,6 +36,8 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val visaActivationRepositoryFactory: VisaActivationRepository.Factory, + private val uiMessageSender: UiMessageSender, + private val analyticsEventsHandler: AnalyticsEventHandler, ) : Model() { private val params = paramsContainer.require() @@ -37,6 +47,10 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( val uiState = _uiState.asStateFlow() val onDone = MutableSharedFlow() + init { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.ActivationScreenOpened) + } + private fun getInitialState(): OnboardingVisaWelcomeUM { return OnboardingVisaWelcomeUM( mode = if (params is Config.WelcomeBack) { @@ -49,6 +63,7 @@ internal class OnboardingVisaWelcomeModel @Inject constructor( } private fun onContinueClick() { + analyticsEventsHandler.send(OnboardingVisaAnalyticsEvent.ButtonActivate) if (params !is Config.WelcomeBack) { modelScope.launch { onDone.emit(DoneEvent.WelcomeDone) } return @@ -68,24 +83,32 @@ 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) + analyticsEventsHandler.send( + VisaAnalyticsEvent.Errors(result.error.code.toString(), ONBOARDING_SOURCE), + ) + 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/model/analytics/OnboardingVisaAnalyticsEvent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/OnboardingVisaAnalyticsEvent.kt new file mode 100644 index 0000000000..25759dce3a --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/OnboardingVisaAnalyticsEvent.kt @@ -0,0 +1,85 @@ +package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +internal sealed class OnboardingVisaAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Onboarding / Visa", event, params) { + + data object ActivationScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Activation Screen Opened", + ) + + data object ButtonActivate : OnboardingVisaAnalyticsEvent( + event = "Button - Activate", + ) + + data object SettingAccessCodeStarted : OnboardingVisaAnalyticsEvent( + event = "Setting Access Code Started", + ) + + data object AccessCodeEntered : OnboardingVisaAnalyticsEvent( + event = "Access Code Entered", + ) + + data object AccessCodeReenterScreen : OnboardingVisaAnalyticsEvent( + event = "Access Code Re-enter Screen", + ) + + data object OnboardingVisa : OnboardingVisaAnalyticsEvent( + event = "Onboarding / Visa", + ) + + data class ChooseWalletScreen( + val type: String, + ) : OnboardingVisaAnalyticsEvent( + event = "Choose Wallet Screen", + params = mapOf("Type" to type), + ) + + data object WalletPrepare : OnboardingVisaAnalyticsEvent( + event = "Wallet Prepare", + ) + + data object ButtonApprove : OnboardingVisaAnalyticsEvent( + event = "Button - Approve", + ) + + data object GoToWebsiteOpened : OnboardingVisaAnalyticsEvent( + event = "Go To Website Opened", + ) + + data object ButtonBrowser : OnboardingVisaAnalyticsEvent( + event = "Button - Browser", + ) + + data object ButtonShareLink : OnboardingVisaAnalyticsEvent( + event = "Button - Share Link", + ) + + data object ActivationInProgressScreen : OnboardingVisaAnalyticsEvent( + event = "Activation In Progress Screen", + ) + + data object PinCodeScreenOpened : OnboardingVisaAnalyticsEvent( + event = "PIN Code Screen Opened", + ) + + data object PinEntered : OnboardingVisaAnalyticsEvent( + event = "PIN Entered", + params = mapOf("Type" to "Visa"), + ) + + data object BiometricScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Biometric Screen Opened", + ) + + data object SuccessScreenOpened : OnboardingVisaAnalyticsEvent( + event = "Success Screen Opened", + ) + + data object ErrorPinValidation : OnboardingVisaAnalyticsEvent( + event = "Error - Pin Validation", + ) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt new file mode 100644 index 0000000000..f31e8c0a83 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/child/welcome/model/analytics/VisaAnalyticsEvent.kt @@ -0,0 +1,23 @@ +package com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +internal const val ONBOARDING_SOURCE = "Onboarding" +internal const val MAIN_SOURCE = "Main" + +internal sealed class VisaAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Visa", event, params) { + + data class Errors( + val errorCode: String, + val source: String, + ) : VisaAnalyticsEvent( + event = "Errors", + params = mapOf( + "Error Code" to errorCode, + "Source" to source, + ), + ) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/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..8bc8b14032 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/model/OnboardingVisaModel.kt @@ -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( @@ -160,8 +159,11 @@ internal class OnboardingVisaModel @Inject constructor( VisaActivationRemoteState.WaitingForActivationFinishing -> { OnboardingVisaRoute.InProgress(from = OnboardingVisaRoute.InProgress.From.PinCode) } - is VisaActivationRemoteState.WaitingPinCode -> { - OnboardingVisaRoute.PinCode(activationOrderInfo = remoteState.activationOrderInfo) + is VisaActivationRemoteState.AwaitingPinCode -> { + OnboardingVisaRoute.PinCode( + activationOrderInfo = remoteState.activationOrderInfo, + pinCodeValidationError = false, + ) } VisaActivationRemoteState.Activated, VisaActivationRemoteState.BlockedForActivation, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt index 9dca6484e9..762fc96544 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/route/OnboardingVisaRoute.kt @@ -48,5 +48,8 @@ internal sealed class OnboardingVisaRoute { } @Serializable - data class PinCode(val activationOrderInfo: VisaActivationOrderInfo) : OnboardingVisaRoute() + data class PinCode( + val activationOrderInfo: VisaActivationOrderInfo, + val pinCodeValidationError: Boolean, + ) : OnboardingVisaRoute() } \ No newline at end of file diff --git a/features/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..fce40fb61c --- /dev/null +++ b/features/send-v2/api/build.gradle.kts @@ -0,0 +1,20 @@ +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) + implementation(projects.domain.nft.models) +} \ No newline at end of file diff --git a/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt new file mode 100644 index 0000000000..2403e179e6 --- /dev/null +++ b/features/send-v2/api/src/main/java/com/tangem/features/send/v2/api/NFTSendComponent.kt @@ -0,0 +1,17 @@ +package com.tangem.features.send.v2.api + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.UserWalletId + +interface NFTSendComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + val nftAsset: NFTAsset, + val nftCollectionName: String, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/send-v2/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/PredefinedValues.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/PredefinedValues.kt new file mode 100644 index 0000000000..adbfd84dfc --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/PredefinedValues.kt @@ -0,0 +1,24 @@ +package com.tangem.features.send.v2.common + +sealed class PredefinedValues { + data object Empty : PredefinedValues() + + sealed class Content : PredefinedValues() { + abstract val amount: String + abstract val address: String + abstract val memo: String? + + data class Deeplink( + override val amount: String, + override val address: String, + override val memo: String?, + val transactionId: String, + ) : Content() + + data class QrCode( + override val amount: String, + override val address: String, + override val memo: String?, + ) : Content() + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt new file mode 100644 index 0000000000..69254405cf --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendBalanceUpdater.kt @@ -0,0 +1,87 @@ +package com.tangem.features.send.v2.common + +import com.tangem.domain.tokens.FetchPendingTransactionsUseCase +import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.txhistory.TxHistoryFeatureToggles +import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter +import com.tangem.utils.coroutines.DelayedWork +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.* + +@Suppress("LongParameterList") +internal class SendBalanceUpdater @AssistedInject constructor( + private val fetchPendingTransactionsUseCase: FetchPendingTransactionsUseCase, + private val updateDelayedNetworkStatusUseCase: UpdateDelayedNetworkStatusUseCase, + private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val getTxHistoryItemsUseCase: GetTxHistoryItemsUseCase, + private val txHistoryFeatureToggles: TxHistoryFeatureToggles, + private val txHistoryContentUpdateEmitter: TxHistoryContentUpdateEmitter, + @DelayedWork private val coroutineScope: CoroutineScope, + @Assisted private val userWallet: UserWallet, + @Assisted private val cryptoCurrency: CryptoCurrency, +) { + fun scheduleUpdates() { + coroutineScope.launch { + listOf( + // we should update network to find pending tx after 1 sec + async { + fetchPendingTransactionsUseCase( + userWalletId = userWallet.walletId, + networks = setOf(cryptoCurrency.network), + ) + }, + // we should update tx history and network for new balances + async { + updateTxHistory() + }, + async { + updateNetworkStatuses() + }, + ).awaitAll() + } + } + + private suspend fun updateNetworkStatuses(delay: Long = BALANCE_UPDATE_DELAY) { + updateDelayedNetworkStatusUseCase( + userWalletId = userWallet.walletId, + network = cryptoCurrency.network, + delayMillis = delay, + refresh = true, + ) + } + + private suspend fun updateTxHistory() { + delay(BALANCE_UPDATE_DELAY) + val txHistoryItemsCountEither = getTxHistoryItemsCountUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + ) + + txHistoryItemsCountEither.onRight { + if (txHistoryFeatureToggles.isFeatureEnabled) { + txHistoryContentUpdateEmitter.triggerUpdate() + } else { + getTxHistoryItemsUseCase( + userWalletId = userWallet.walletId, + currency = cryptoCurrency, + refresh = true, + ) + } + } + } + + private companion object { + const val BALANCE_UPDATE_DELAY = 11_000L + } + + @AssistedFactory + interface Factory { + fun create(cryptoCurrency: CryptoCurrency, userWallet: UserWallet): SendBalanceUpdater + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt new file mode 100644 index 0000000000..0ce1403ae8 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/SendNavigationModelCallback.kt @@ -0,0 +1,7 @@ +package com.tangem.features.send.v2.common + +import com.tangem.features.send.v2.common.ui.state.NavigationUM + +internal interface SendNavigationModelCallback { + fun onNavigationResult(navigationUM: NavigationUM) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt new file mode 100644 index 0000000000..265067a7df --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendContent.kt @@ -0,0 +1,54 @@ +package com.tangem.features.send.v2.common.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.ui.state.NavigationUM + +@Composable +internal fun SendContent(navigationUM: NavigationUM, stackState: ChildStack) { + Column( + modifier = Modifier.Companion + .background(color = TangemTheme.colors.background.tertiary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.Companion.CenterHorizontally, + ) { + SendAppBar(navigationUM = navigationUM) + Children( + stack = stackState, + animation = stackAnimation(slide()), + modifier = Modifier.weight(1f), + ) { + it.instance.Content(Modifier.weight(1f)) + } + SendNavigationButtons(navigationUM = 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/common/ui/SendNavigationButtons.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendNavigationButtons.kt new file mode 100644 index 0000000000..581e14ba92 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/SendNavigationButtons.kt @@ -0,0 +1,128 @@ +package com.tangem.features.send.v2.common.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.ui.state.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/common/ui/state/ConfirmUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/state/ConfirmUM.kt new file mode 100644 index 0000000000..aef792a091 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/state/ConfirmUM.kt @@ -0,0 +1,31 @@ +package com.tangem.features.send.v2.common.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/common/ui/state/NavigationUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/state/NavigationUM.kt new file mode 100644 index 0000000000..404f1a3e73 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/common/ui/state/NavigationUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.send.v2.common.ui.state + +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/di/CommonSendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/CommonSendModelModule.kt new file mode 100644 index 0000000000..bb0fc819ab --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/CommonSendModelModule.kt @@ -0,0 +1,38 @@ +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.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 CommonSendModelModule { + + @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(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/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..3c2f9bcf9f --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/di/SendFeatureModule.kt @@ -0,0 +1,38 @@ +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.NFTSendComponent +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 com.tangem.features.send.v2.sendnft.DefaultNFTSendComponent +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 + + @Binds + @Singleton + fun provideNFTSendComponentFactory(impl: DefaultNFTSendComponent.Factory): NFTSendComponent.Factory +} \ 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..07a7427df4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/DefaultSendComponent.kt @@ -0,0 +1,235 @@ +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.common.PredefinedValues +import com.tangem.features.send.v2.common.ui.SendContent +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.send.confirm.SendConfirmComponent +import com.tangem.features.send.v2.send.model.SendModel +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( + navigationUM = state.navigationUM, + stackState = stackState, + ) + } + + private fun createChild(route: SendRoute, factoryContext: AppComponentContext) = when (route) { + SendRoute.Empty -> getStubComponent() + is SendRoute.Destination -> getDestinationComponent(factoryContext, route) + is SendRoute.Amount -> getAmountComponent(factoryContext, route) + is SendRoute.Fee -> getFeeComponent(factoryContext) + SendRoute.Confirm -> getConfirmComponent(factoryContext) + } + + private fun 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, + predefinedValues = model.predefinedValues, + ), + ) + + 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) { + PredefinedValues.Content.Deeplink( + amount = predefinedAmount, + address = predefinedAddress, + memo = params.tag, + transactionId = predefinedTxId, + ) + } else { + 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..3acce0b9c5 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/SendConfirmComponent.kt @@ -0,0 +1,152 @@ +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.common.PredefinedValues +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.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.subcomponents.amount.SendAmountBlockComponent +import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationBlockComponent +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams +import com.tangem.features.send.v2.subcomponents.fee.SendFeeBlockComponent +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import com.tangem.features.send.v2.subcomponents.notifications.NotificationsComponent +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData +import com.tangem.utils.extensions.orZero +import kotlinx.coroutines.flow.* + +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 = DestinationBlockParams( + state = model.uiState.value.destinationUM, + analyticsCategoryName = params.analyticsCategoryName, + userWalletId = params.userWallet.walletId, + cryptoCurrency = params.cryptoCurrencyStatus.currency, + blockClickEnableFlow = blockClickEnableFlow.asStateFlow(), + predefinedValues = params.predefinedValues, + ), + 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(), + predefinedValues = params.predefinedValues, + ), + 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.confirmData.enteredAmount.orZero(), + destinationAddress = model.confirmData.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, + notificationData = NotificationData( + destinationAddress = model.confirmData.enteredDestination.orEmpty(), + memo = model.confirmData.enteredMemo, + amountValue = model.confirmData.enteredAmount.orZero(), + reduceAmountBy = model.confirmData.reduceAmountBy.orZero(), + isIgnoreReduce = model.confirmData.isIgnoreReduce, + fee = model.confirmData.fee, + feeError = model.confirmData.feeError, + ), + ), + ) + + 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, + ) + + 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/ConfirmData.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/ConfirmData.kt new file mode 100644 index 0000000000..e6aa732445 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/ConfirmData.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.v2.send.confirm.model + +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.transaction.error.GetFeeError +import java.math.BigDecimal + +data class ConfirmData( + val enteredAmount: BigDecimal?, + val reduceAmountBy: BigDecimal, + val isIgnoreReduce: Boolean, + val enteredDestination: String?, + val enteredMemo: String?, + val fee: Fee?, + val feeError: GetFeeError?, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/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..cb79ab86b1 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -0,0 +1,496 @@ +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.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.IsAmountSubtractAvailableUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +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.utils.convertToSdkAmount +import com.tangem.features.send.v2.common.SendBalanceUpdater +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource +import com.tangem.features.send.v2.send.analytics.SendAnalyticHelper +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.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.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.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.orZero +import com.tangem.utils.extensions.stripZeroPlainString +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import timber.log.Timber +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 isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, + private val sendFeeCheckReloadTrigger: SendFeeCheckReloadTrigger, + private val notificationsUpdateTrigger: NotificationsUpdateTrigger, + private val alertFactory: SendConfirmAlertFactory, + private val sendAnalyticHelper: SendAnalyticHelper, + private val urlOpener: UrlOpener, + private val shareManager: ShareManager, + sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, +) : 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 sendBalanceUpdater = sendBalanceUpdaterFactory.create(cryptoCurrency, userWallet) + + 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 confirmData: ConfirmData + get() = ConfirmData( + enteredAmount = amountState?.amountTextField?.cryptoAmount?.value, + enteredMemo = destinationUM?.memoTextField?.value, + reduceAmountBy = amountState?.reduceAmountBy.orZero(), + isIgnoreReduce = amountState?.isIgnoreReduce == true, + enteredDestination = destinationUM?.addressTextField?.value, + fee = feeSelectorUM?.selectedFee, + feeError = (feeUM?.feeSelectorUM as? FeeSelectorUM.Error)?.error, + ) + + private var sendIdleTimer: Long = 0L + private var isAmountSubtractAvailable = false + + 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 = confirmData.fee?.amount?.value + + val receivingAmount = if (amountValue != null && feeValue != null) { + checkAndCalculateSubtractedAmount( + isAmountSubtractAvailable = isAmountSubtractAvailable, + cryptoCurrencyStatus = cryptoCurrencyStatus, + amountValue = confirmData.enteredAmount.orZero(), + feeValue = feeValue, + reduceAmountBy = confirmData.reduceAmountBy, + ) + } else { + null + } + + val amount = receivingAmount?.convertToSdkAmount(cryptoCurrency) + + saveBlockchainErrorUseCase( + error = BlockchainErrorInfo( + errorMessage = errorMessage, + blockchainId = cryptoCurrency.network.id.value, + derivationPath = cryptoCurrency.network.derivationPath.value, + destinationAddress = confirmData.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 = confirmData.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() + sendBalanceUpdater.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 == confirmData.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 subscribeOnCheckFeeResultUpdates() { + sendFeeCheckReloadTrigger.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 = confirmData.enteredDestination.orEmpty(), + memo = confirmData.enteredMemo, + amountValue = confirmData.enteredAmount.orZero(), + reduceAmountBy = confirmData.reduceAmountBy.orZero(), + isIgnoreReduce = confirmData.isIgnoreReduce, + fee = confirmData.fee, + feeError = confirmData.feeError, + ), + ) + _uiState.update { + 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 + } +} \ 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..c980c558a7 --- /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.common.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..9a8e44344a --- /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.common.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..6eed2edabb --- /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.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.utils.transformer.Transformer + +internal class SendConfirmSentStateTransformer( + val txData: TransactionData.Uncompiled, + val txUrl: String, +) : 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..853e5bcdb5 --- /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.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.subcomponents.fee.model.checkIfFeeTooHigh +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeSelectorUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeType +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..b734399624 --- /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.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.send.ui.state.SendUM +import com.tangem.features.send.v2.subcomponents.amount.SendAmountBlockComponent +import com.tangem.features.send.v2.subcomponents.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/di/SendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/di/SendModelModule.kt new file mode 100644 index 0000000000..7405859941 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/di/SendModelModule.kt @@ -0,0 +1,26 @@ +package com.tangem.features.send.v2.send.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.send.confirm.model.SendConfirmModel +import com.tangem.features.send.v2.send.model.SendModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface SendModelModule { + + @Binds + @IntoMap + @ClassKey(SendModel::class) + fun provideSendModel(model: SendModel): Model + + @Binds + @IntoMap + @ClassKey(SendConfirmModel::class) + fun provideSendConfirmModel(model: SendConfirmModel): Model +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt new file mode 100644 index 0000000000..ad59d24108 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -0,0 +1,267 @@ +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.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.send.confirm.SendConfirmComponent +import com.tangem.features.send.v2.send.confirm.model.SendConfirmAlertFactory +import com.tangem.features.send.v2.send.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 predefinedValues: PredefinedValues = PredefinedValues.Empty + + 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() + predefinedValues = PredefinedValues.Content.QrCode( + amount = parsedQrCode?.amount?.parseBigDecimal(cryptoCurrency.decimals).orEmpty(), + address = parsedQrCode?.address.orEmpty(), + memo = parsedQrCode?.memo, + ) + } + + 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/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..8359df5247 --- /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.ui.state.NavigationUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM + +internal data class 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/sendnft/DefaultNFTSendComponent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt new file mode 100644 index 0000000000..8074966261 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/DefaultNFTSendComponent.kt @@ -0,0 +1,89 @@ +package com.tangem.features.send.v2.sendnft + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.send.v2.api.NFTSendComponent +import com.tangem.features.send.v2.sendnft.model.NFTSendModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNFTSendComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: NFTSendComponent.Params, +) : NFTSendComponent, AppComponentContext by appComponentContext { + + // private val stackNavigation = StackNavigation() + // + // private val innerRouter = InnerRouter( + // stackNavigation = stackNavigation, + // popCallback = { onChildBack() }, + // ) + + // private val initialRoute = NFTSendRoute.Empty + // private val currentRouteFlow = MutableStateFlow(initialRoute) + + private val model: NFTSendModel = getOrCreateModel(params = params/*, router = innerRouter*/) + + // private val childStack = childStack( + // key = "NFTSendInnerStack", + // source = stackNavigation, + // serializer = null, + // initialConfiguration = initialRoute, + // handleBackButton = true, + // childFactory = { configuration, factoryContext -> + // // todo + // ComposableContentComponent { } + // }, + // ) + + init { + // childStack.subscribe( + // lifecycle = lifecycle, + // mode = ObserveLifecycleMode.CREATE_DESTROY, + // ) { stack -> + // componentScope.launch { + // when (val activeComponent = stack.active.instance) { + // is SendDestinationComponent -> { + // // analyticsEventHandler.send(SendAnalyticEvents.AddressScreenOpened) + // activeComponent.updateState(model.uiState.value.destinationUM) + // } + // is SendFeeComponent -> { + // // analyticsEventHandler.send(SendAnalyticEvents.FeeScreenOpened) + // } + // } + // currentRouteFlow.emit(stack.active.configuration) + // } + // } + } + + @Composable + override fun Content(modifier: Modifier) { + // val stackState by childStack.subscribeAsState() + // val state by model.uiState.collectAsStateWithLifecycle() + + BackHandler(onBack = ::onChildBack) + // TODO + } + + private fun onChildBack() { + // TODO + // val isEmptyRoute = childStack.value.active.configuration == NFTSendRoute.Empty + // val isEmptyStack = childStack.value.backStack.isEmpty() + // val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success + // + // if (isEmptyRoute || isEmptyStack || isSuccess) { + // router.pop() + // } else { + // stackNavigation.pop() + // } + } + + @AssistedFactory + interface Factory : NFTSendComponent.Factory { + override fun create(context: AppComponentContext, params: NFTSendComponent.Params): DefaultNFTSendComponent + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt new file mode 100644 index 0000000000..cf70962383 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/di/NFTSendModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.send.v2.sendnft.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.send.v2.sendnft.model.NFTSendModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface NFTSendModelModule { + + @Binds + @IntoMap + @ClassKey(NFTSendModel::class) + fun provideNFTSendModel(model: NFTSendModel): Model +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt new file mode 100644 index 0000000000..3d5d6d178a --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -0,0 +1,144 @@ +package com.tangem.features.send.v2.sendnft.model + +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.send.v2.api.NFTSendComponent +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponent +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponent +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.properties.Delegates + +internal interface SendNFTComponentCallback : + SendFeeComponent.ModelCallback, + SendDestinationComponent.ModelCallback + +@Suppress("LongParameterList") +@Stable +@ModelScoped +internal class NFTSendModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase, + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, +) : Model(), SendNFTComponentCallback { + + val params: NFTSendComponent.Params = paramsContainer.require() + private val userWalletId = params.userWalletId + + private val _uiState = MutableStateFlow(initialState()) + val uiState = _uiState.asStateFlow() + + private val _isBalanceHiddenFlow = MutableStateFlow(false) + val isBalanceHiddenFlow = _isBalanceHiddenFlow.asStateFlow() + + var cryptoCurrency: CryptoCurrency by Delegates.notNull() + var userWallet: UserWallet by Delegates.notNull() + var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + var feeCryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() + var appCurrency: AppCurrency = AppCurrency.Default + + init { + subscribeOnCurrencyStatusUpdates() + initAppCurrency() + } + + override fun onNavigationResult(navigationUM: NavigationUM) { + _uiState.update { it.copy(navigationUM = navigationUM) } + } + + override fun onDestinationResult(destinationUM: DestinationUM) { + _uiState.update { it.copy(destinationUM = destinationUM) } + + // todo + } + + override fun onFeeResult(feeUM: FeeUM) { + _uiState.update { it.copy(feeUM = feeUM) } + router.pop() + } + + private fun initAppCurrency() { + modelScope.launch { + appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default } + } + } + + private fun subscribeOnCurrencyStatusUpdates() { + modelScope.launch { + getUserWalletUseCase(params.userWalletId).fold( + ifRight = { wallet -> + userWallet = wallet + + // cryptoCurrency = getCryptoCurrenciesUseCase(userWalletId).getOrNull() + // ?.filterIsInstance() + // ?.firstOrNull { it.network == nftAsset.network } + // ?: return@launch + + getCurrenciesStatusUpdates( + isSingleWalletWithToken = wallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + }, + ifLeft = { + // sendConfirmAlertFactory.getGenericErrorState(::onFailedTxEmailClick) + return@launch + }, + ) + } + } + + private fun getCurrenciesStatusUpdates(isSingleWalletWithToken: Boolean) { + getCurrencyStatusUpdatesUseCase( + userWalletId = userWalletId, + currencyId = cryptoCurrency.id, + isSingleWalletWithTokens = isSingleWalletWithToken, + ).onEach { maybeCryptoCurrency -> + maybeCryptoCurrency.fold( + ifRight = { cryptoStatus -> + cryptoCurrencyStatus = cryptoStatus + feeCryptoCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoStatus, + ).getOrNull() ?: cryptoStatus + + // router.push(NFTSendRoute.Destination(isEditMode = false)) + }, + ifLeft = { + // sendConfirmAlertFactory.getGenericErrorState { + // onFailedTxEmailClick(it.toString()) + // } + }, + ) + }.launchIn(modelScope) + } + + private fun initialState(): NFTSendUM = NFTSendUM( + destinationUM = DestinationUM.Empty(), + feeUM = FeeUM.Empty(), + confirmUM = ConfirmUM.Empty, + navigationUM = NavigationUM.Empty, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt new file mode 100644 index 0000000000..526bfdd621 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/ui/state/NFTSendUM.kt @@ -0,0 +1,13 @@ +package com.tangem.features.send.v2.sendnft.ui.state + +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.common.ui.state.ConfirmUM +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +import com.tangem.features.send.v2.subcomponents.fee.ui.state.FeeUM + +internal data class NFTSendUM( + val destinationUM: DestinationUM, + val feeUM: FeeUM, + val confirmUM: ConfirmUM, + val navigationUM: NavigationUM, +) \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/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..b05e56c031 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/SendAmountBlockComponent.kt @@ -0,0 +1,47 @@ +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.common.PredefinedValues +import com.tangem.features.send.v2.subcomponents.amount.SendAmountComponentParams.AmountBlockParams +import com.tangem.features.send.v2.subcomponents.amount.model.SendAmountModel +import kotlinx.coroutines.flow.launchIn +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.predefinedValues is PredefinedValues.Content.Deeplink, + 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..05f4e2a828 --- /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.common.PredefinedValues +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 predefinedValues: PredefinedValues + + 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 predefinedValues: PredefinedValues, + 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 predefinedValues: PredefinedValues, + val blockClickEnableFlow: StateFlow, + ) : 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..18e832c369 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -0,0 +1,251 @@ +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.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.send.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.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 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 = "", + ), + ) + } + val predefinedValues = params.predefinedValues as? PredefinedValues.Content + if (predefinedValues?.amount != null) { + onAmountValueChange(predefinedValues.amount) + } + } + } + + 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, + ), + ) + } + .launchIn(modelScope) + } + + private fun subscribeOnAmountReduceByTriggerUpdates() { + sendAmountReduceListener.reduceByTriggerFlow + .onEach { reduceByData -> + _uiState.update( + AmountReduceByTransformer( + cryptoCurrencyStatus = cryptoCurrencyStatus, + minimumTransactionAmount = minAmountBoundary, + value = reduceByData, + ), + ) + } + .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..5f500027fa --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationBlockComponent.kt @@ -0,0 +1,46 @@ +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.PredefinedValues +import com.tangem.features.send.v2.subcomponents.destination.model.SendDestinationModel +import com.tangem.features.send.v2.subcomponents.destination.ui.DestinationBlock +import com.tangem.features.send.v2.subcomponents.destination.ui.state.DestinationUM +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.predefinedValues is PredefinedValues.Content.Deeplink, + 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..15b8c7bb6c --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/SendDestinationComponentParams.kt @@ -0,0 +1,38 @@ +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.common.PredefinedValues +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 predefinedValues: PredefinedValues, + ) : SendDestinationComponentParams() +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/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..c31c86c96d --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -0,0 +1,344 @@ +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.PredefinedValues +import com.tangem.features.send.v2.common.ui.state.NavigationUM +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.send.SendRoute +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents +import com.tangem.features.send.v2.send.analytics.SendAnalyticEvents.SendScreenSource +import com.tangem.features.send.v2.send.ui.state.ButtonsUM +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.v2.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams +import com.tangem.features.send.v2.subcomponents.destination.analytics.EnterAddressSource +import com.tangem.features.send.v2.subcomponents.destination.analytics.SendDestinationAnalyticEvents +import com.tangem.features.send.v2.subcomponents.destination.model.transformers.* +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.value is DestinationUM.Empty) { + _uiState.update( + SendDestinationInitialStateTransformer( + cryptoCurrency = cryptoCurrency, + isInitialized = true, + ), + ) + val params = params as? DestinationBlockParams + val predefinedValues = params?.predefinedValues as? PredefinedValues.Content.Deeplink + if (predefinedValues?.address != null) { + _uiState.update( + SendDestinationPredefinedStateTransformer( + address = predefinedValues.address, + memo = predefinedValues.memo, + ), + ) + } + } + } + + 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/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..6502eb6fa2 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/SendFeeReloadTrigger.kt @@ -0,0 +1,59 @@ +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 { + + /** Flow triggers fee reload */ + val reloadTriggerFlow: Flow + + /** Trigger fee update */ + suspend fun triggerUpdate() +} + +interface SendFeeCheckReloadTrigger { + + /** + * 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 + + /** Trigger return callback with check result */ + suspend fun callbackCheckResult(isSuccess: Boolean) + + /** Trigger fee check reload */ + suspend fun triggerCheckUpdate() +} + +@Singleton +internal class DefaultSendFeeReloadTrigger @Inject constructor() : SendFeeReloadTrigger, SendFeeCheckReloadTrigger { + + 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() { + _reloadTriggerFlow.emit(Unit) + } + + 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..27cc5cacd4 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/di/SendFeeModule.kt @@ -0,0 +1,27 @@ +package com.tangem.features.send.v2.subcomponents.fee.di + +import com.tangem.features.send.v2.subcomponents.fee.DefaultSendFeeReloadTrigger +import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger +import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadTrigger +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 SendFeeModule { + + @Provides + @Singleton + fun provideSendFeeReloadTrigger(): SendFeeReloadTrigger { + return DefaultSendFeeReloadTrigger() + } + + @Provides + @Singleton + fun provideSendFeeCheckReloadTrigger(): SendFeeCheckReloadTrigger { + return DefaultSendFeeReloadTrigger() + } +} \ 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..3a8d73d809 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/fee/model/SendFeeModel.kt @@ -0,0 +1,306 @@ +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.ui.state.NavigationUM +import com.tangem.features.send.v2.impl.R +import com.tangem.features.send.v2.send.ui.state.ButtonsUM +import com.tangem.features.send.v2.subcomponents.fee.SendFeeCheckReloadTrigger +import com.tangem.features.send.v2.subcomponents.fee.SendFeeComponentParams +import com.tangem.features.send.v2.subcomponents.fee.SendFeeReloadTrigger +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.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 feeReloadTrigger: SendFeeReloadTrigger, + 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() + loadFee() + } + + fun updateState(state: FeeUM) { + _uiState.value = state + } + + override fun feeReload() = loadFee() + + 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() { + feeReloadTrigger.reloadTriggerFlow + .onEach { feeReload() } + .launchIn(modelScope) + } + + private fun subscribeOnFeeCheckReloadTriggerUpdates() { + feeCheckReloadTrigger.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() { + modelScope.launch { + val isShowLoading = (uiState.value as? FeeUM.Content)?.feeSelectorUM !is FeeSelectorUM.Content + if (isShowLoading) { + _uiState.update(SendFeeLoadingTransformer) + } + callFeeUseCase().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 { + callFeeUseCase().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(::loadFee) + updateFeeNotifications() + }, + ) + }.saveIn(feeJobHolder) + } + + private suspend fun callFeeUseCase() = getFeeUseCase.invoke( + amount = params.sendAmount, + destination = params.destinationAddress, + userWallet = params.userWallet, + cryptoCurrency = cryptoCurrencyStatus.currency, + ) + + 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..4f163c1a61 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/NotificationsComponent.kt @@ -0,0 +1,48 @@ +package com.tangem.features.send.v2.subcomponents.notifications + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +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.wallets.models.UserWalletId +import com.tangem.features.send.v2.subcomponents.notifications +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationData +import com.tangem.features.send.v2.subcomponents.notifications.model.NotificationsModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.StateFlow + +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 notificationData: NotificationData, + ) +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/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..34013568f6 --- /dev/null +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -0,0 +1,349 @@ +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.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") +@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 notificationData = params.notificationData + + 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) { + notificationData = data + buildNotifications() + } + + private suspend fun buildNotifications() { + val notifications = buildList { + addFeeUnreachableNotification( + tokenStatus = cryptoCurrencyStatus, + coinStatus = feeCryptoCurrencyStatus, + feeError = notificationData.feeError, + onReload = { + modelScope.launch { + sendFeeReloadTrigger.triggerUpdate() + } + }, + onClick = ::showTokenDetails, + ) + addDomainNotifications() + } + + 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() = with(notificationData) { + 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 = enteredAmount.convertToSdkAmount(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 = notificationData.isIgnoreReduce, + onReduceClick = { reduceBy, reduceByDiff, _ -> + modelScope.launch { + sendAmountReduceTrigger.triggerReduceBy( + ReduceByData( + reduceAmountBy = reduceBy, + reduceAmountByDiff = reduceByDiff, + ), + ) + } + }, + onCloseClick = { + modelScope.launch { + buildNotifications() + } + }, + ) + } +} \ 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..d122cd48bf 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -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() { @@ -284,12 +302,13 @@ internal class StakingModel @Inject constructor( ) modelScope.launch { feeLoader.getFee( - onStakingFee = { gasEstimate -> + onStakingFee = { gasEstimate, isFeeApproximate -> stateController.update( SetConfirmationStateAssentTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = gasEstimate, + isFeeApproximate = isFeeApproximate, cryptoCurrencyStatus = cryptoCurrencyStatus, ), ) @@ -345,13 +364,14 @@ internal class StakingModel @Inject constructor( stakingEventFactory.createSendTransactionErrorAlert(error) stateController.update(SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus)) }, - onFeeIncreased = { increasedFee -> + onFeeIncreased = { increasedFee, isFeeApproximate -> stateController.updateAll( SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), SetConfirmationStateAssentTransformer( appCurrencyProvider = Provider { appCurrency }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = increasedFee, + isFeeApproximate = isFeeApproximate, cryptoCurrencyStatus = cryptoCurrencyStatus, ), ) @@ -497,19 +517,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/helpers/StakingFeeTransactionLoader.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt index 1688f31bdf..8043052fe7 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingFeeTransactionLoader.kt @@ -18,6 +18,7 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.staking.getCurrentToken import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.staking.impl.presentation.state.StakingStateController import com.tangem.features.staking.impl.presentation.state.StakingStates @@ -38,13 +39,14 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( private val stateController: StakingStateController, private val getFeeUseCase: GetFeeUseCase, private val estimateGasUseCase: EstimateGasUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, @Assisted private val yield: Yield, ) { suspend fun getFee( - onStakingFee: (Fee) -> Unit, + onStakingFee: (Fee, Boolean) -> Unit, onStakingFeeError: (StakingError) -> Unit, onApprovalFee: (TransactionFee) -> Unit, onFeeError: (GetFeeError) -> Unit, @@ -91,7 +93,7 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( amount: BigDecimal, validatorAddress: String, onStakingFeeError: (StakingError) -> Unit, - onStakingFee: (Fee) -> Unit, + onStakingFee: (Fee, Boolean) -> Unit, ) { val sourceAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value ?: error("No available address") @@ -144,14 +146,14 @@ internal class StakingFeeTransactionLoader @AssistedInject constructor( } } + val amount = Amount( + currencySymbol = gasEstimate.token.symbol, + value = gasEstimate.amount, + decimals = gasEstimate.token.decimals, + ) onStakingFee( - Fee.Common( - Amount( - currencySymbol = gasEstimate.token.symbol, - value = gasEstimate.amount, - decimals = gasEstimate.token.decimals, - ), - ), + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt index 27d87225e0..aa0afb181b 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakingTransactionSender.kt @@ -5,7 +5,10 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionSender import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.domain.staking.* +import com.tangem.domain.staking.GetConstructedStakingTransactionUseCase +import com.tangem.domain.staking.GetStakingTransactionsUseCase +import com.tangem.domain.staking.SaveUnsubmittedHashUseCase +import com.tangem.domain.staking.SubmitHashUseCase import com.tangem.domain.staking.model.SubmitHashData import com.tangem.domain.staking.model.stakekit.NetworkType import com.tangem.domain.staking.model.stakekit.PendingAction @@ -19,6 +22,7 @@ import com.tangem.domain.staking.model.stakekit.transaction.StakingTransactionTy import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.staking.getCurrentToken import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.utils.convertToSdkAmount @@ -49,6 +53,7 @@ internal class StakingTransactionSender @AssistedInject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val submitHashUseCase: SubmitHashUseCase, private val saveUnsubmittedHashUseCase: SaveUnsubmittedHashUseCase, + private val isFeeApproximateUseCase: IsFeeApproximateUseCase, @Assisted private val cryptoCurrencyStatus: CryptoCurrencyStatus, @Assisted private val userWallet: UserWallet, @Assisted private val yield: Yield, @@ -63,7 +68,7 @@ internal class StakingTransactionSender @AssistedInject constructor( onConstructError: (StakingError) -> Unit, onSendSuccess: (String) -> Unit, onSendError: (SendTransactionError?) -> Unit, - onFeeIncreased: (Fee) -> Unit, + onFeeIncreased: (Fee, Boolean) -> Unit, ) { val state = stateController.value @@ -101,10 +106,10 @@ internal class StakingTransactionSender @AssistedInject constructor( onSendError = onSendError, ) } else { + val amount = fee.amount.copy(value = totalFee) onFeeIncreased( - Fee.Common( - fee.amount.copy(value = totalFee), - ), + Fee.Common(amount), + isFeeApproximateUseCase(networkId = cryptoCurrencyStatus.currency.network.id, amountType = amount.type), ) } } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/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/SetConfirmationStateAssentTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt index 56e01d3a83..c9a8054cc5 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetConfirmationStateAssentTransformer.kt @@ -13,6 +13,7 @@ internal class SetConfirmationStateAssentTransformer( private val appCurrencyProvider: Provider, private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, private val fee: Fee, + private val isFeeApproximate: Boolean, private val cryptoCurrencyStatus: CryptoCurrencyStatus, ) : Transformer { @@ -31,7 +32,7 @@ internal class SetConfirmationStateAssentTransformer( rate = feeCryptoCurrencyStatus?.value?.fiatRate, isFeeConvertibleToFiat = isFeeConvertibleToFiat, appCurrency = appCurrencyProvider(), - isFeeApproximate = false, + isFeeApproximate = isFeeApproximate, ), isPrimaryButtonEnabled = with(cryptoCurrencyStatus.value) { sources.yieldBalanceSource.isActual() && sources.networkSource.isActual() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/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/state/utils/StakingRewardsUtils.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt index 510266948e..9ddc12135c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/utils/StakingRewardsUtils.kt @@ -5,8 +5,10 @@ import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.COSMOS_SCHEDULE import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.SOLANA_SCHEDULE +import com.tangem.features.staking.impl.presentation.state.utils.StakingRewardSchedule.TON_SCHEDULE import com.tangem.lib.crypto.BlockchainUtils.isCosmos import com.tangem.lib.crypto.BlockchainUtils.isSolana +import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.lib.crypto.BlockchainUtils.isTron import com.tangem.utils.StringsSigns.MINUS import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE @@ -14,6 +16,7 @@ import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE private data object StakingRewardSchedule { val COSMOS_SCHEDULE = 5 to 12 val SOLANA_SCHEDULE = 2 to 3 + val TON_SCHEDULE = 1 to 2 } internal fun getRewardScheduleText( @@ -71,6 +74,15 @@ private fun getCustomRewardSchedule(networkId: String, decapitalize: Boolean = f ) } isTron(networkId) -> resourceReference(id = R.string.staking_reward_schedule_day, decapitalize = decapitalize) + isTon(networkId) -> combinedReference( + resourceReference(id = R.string.staking_reward_schedule_each_plural, decapitalize = decapitalize), + stringReference(NON_BREAKING_SPACE.toString()), + stringReference("${TON_SCHEDULE.first}$MINUS${TON_SCHEDULE.second}$NON_BREAKING_SPACE"), + pluralReference( + id = R.plurals.common_days_no_param, + count = TON_SCHEDULE.second, + ), + ) else -> null } } \ No newline at end of file diff --git a/features/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/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..595cdad4de 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -84,12 +84,15 @@ dependencies { implementation(projects.domain.analytics) implementation(projects.domain.visa) implementation(projects.domain.staking.models) + implementation(projects.domain.staking) implementation(projects.domain.markets.models) implementation(projects.domain.feedback) implementation(projects.domain.onramp.models) implementation(projects.domain.onramp) implementation(projects.domain.promo) implementation(projects.domain.promo.models) + implementation(projects.domain.nft) + implementation(projects.domain.nft.models) //TODO: Create api/impl modules for onboarding [REDACTED_JIRA] implementation(projects.features.onboarding) @@ -108,6 +111,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..9aed66d0f3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/VisaWalletIntents.kt @@ -1,84 +1,56 @@ package com.tangem.feature.wallet.child.wallet.model.intents import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped -import com.tangem.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.feedback.GetCardInfoUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.visa.GetVisaCurrencyUseCase import com.tangem.domain.visa.GetVisaTxDetailsUseCase -import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.visa.model.VisaTxDetails +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.BalancesAndLimitsBottomSheetConverter import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.VisaTxDetailsBottomSheetConverter 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) fun onExploreClick(exploreUrl: String) + + fun onDisputeClick(txDetails: VisaTxDetails) } +@Suppress("LongParameterList") @ModelScoped internal class VisaWalletIntentsImplementor @Inject constructor( private val stateController: WalletStateController, private val eventSender: WalletEventSender, - private val getCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase, private val getVisaCurrencyUseCase: GetVisaCurrencyUseCase, private val getVisaTxDetailsUseCase: GetVisaTxDetailsUseCase, + private val getCardInfoUseCase: GetCardInfoUseCase, + private val getUserWalletsUseCase: GetWalletsUseCase, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val dispatchers: CoroutineDispatcherProvider, + private val analyticsEventHandler: AnalyticsEventHandler, ) : BaseWalletClickIntents(), VisaWalletIntents { private val balancesAndLimitsBottomSheetConverter by lazy(mode = LazyThreadSafetyMode.NONE) { - BalancesAndLimitsBottomSheetConverter(eventSender) - } - - 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 */ }, - ) + BalancesAndLimitsBottomSheetConverter(eventSender, analyticsEventHandler) } override fun onBalancesAndLimitsClick() { + analyticsEventHandler.send(MainScreenAnalyticsEvent.LimitsClicked) modelScope.launch(dispatchers.main) { val userWalletId = stateController.getSelectedWalletId() val balancesAndLimits = getVisaCurrencyUseCase(userWalletId) @@ -95,14 +67,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() @@ -127,6 +91,23 @@ internal class VisaWalletIntentsImplementor @Inject constructor( } override fun onExploreClick(exploreUrl: String) { + analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonExplore) router.openUrl(exploreUrl) } + + override fun onDisputeClick(txDetails: VisaTxDetails) { + modelScope.launch { + val userWalletId = stateController.getSelectedWalletId() + val userWallet = getUserWalletsUseCase.invokeSync() + .firstOrNull { it.walletId == userWalletId } ?: return@launch + val cardInfo = getCardInfoUseCase.invoke(userWallet.scanResponse).getOrNull() ?: return@launch + + sendFeedbackEmailUseCase( + FeedbackEmailType.Visa.Dispute( + cardInfo = cardInfo, + visaTxDetails = txDetails, + ), + ) + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/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..be96aa27a4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -11,10 +11,11 @@ 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.analytics.models.AnalyticsEvent import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.WrappedList @@ -56,7 +57,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 @@ -79,7 +79,7 @@ interface WalletCurrencyActionsClickIntents { unavailabilityReason: ScenarioUnavailabilityReason, ) - fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent? = null) fun onStakeClick(cryptoCurrencyStatus: CryptoCurrencyStatus, yield: Yield?) @@ -95,7 +95,7 @@ interface WalletCurrencyActionsClickIntents { fun onAnalyticsClick(cryptoCurrencyStatus: CryptoCurrencyStatus) - fun onMultiWalletBuyClick(userWalletId: UserWalletId) + fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) fun onMultiWalletSellClick(userWalletId: UserWalletId) @@ -125,7 +125,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( @@ -153,7 +152,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( appRouter.push(route) } - override fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + override fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus, event: AnalyticsEvent?) { val userWalletId = stateHolder.getSelectedWalletId() analyticsEventHandler.send( @@ -168,10 +167,12 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( event = TokenReceiveAnalyticsEvent.ReceiveScreenOpened(cryptoCurrencyStatus.currency.symbol), ) + event?.let { analyticsEventHandler.send(it) } + stateHolder.showBottomSheet( createReceiveBottomSheetContent( currency = cryptoCurrencyStatus.currency, - addresses = cryptoCurrencyStatus.value.networkAddress?.availableAddresses ?: return, + addresses = cryptoCurrencyStatus.value.networkAddress ?: return, ), userWalletId, ) @@ -191,13 +192,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)) @@ -470,7 +473,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } - override fun onMultiWalletBuyClick(userWalletId: UserWalletId) { + override fun onMultiWalletBuyClick(userWalletId: UserWalletId, screenType: String) { onMultiWalletActionClick( statusFlow = if (onrampFeatureToggles.isFeatureEnabled) { rampStateManager.getExpressInitializationStatus(userWalletId) @@ -478,7 +481,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( rampStateManager.getBuyInitializationStatus() }, route = AppRoute.BuyCrypto(userWalletId = userWalletId), - eventCreator = MainScreenAnalyticsEvent::ButtonBuy, + eventCreator = { MainScreenAnalyticsEvent.ButtonBuy(status = it, screenType = screenType) }, ) } @@ -490,7 +493,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 +511,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 +640,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/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..a43a0ef835 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/VisaTxDetailsBottomSheetConfig.kt @@ -6,6 +6,7 @@ import kotlinx.collections.immutable.ImmutableList internal data class VisaTxDetailsBottomSheetConfig( val transaction: Transaction, val requests: ImmutableList, + val onDisputeClick: () -> Unit, ) : TangemBottomSheetConfigContent { data class Transaction( @@ -13,7 +14,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 +27,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 65% 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..1dc3f95565 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 @@ -2,11 +2,14 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import arrow.core.Either import arrow.core.getOrElse +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent.Companion.VISA_TYPE import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat 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 +18,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 +41,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 +121,49 @@ 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 persistentListOf( + WalletManageButton.Receive( + enabled = true, + dimContent = false, + onClick = { + clickIntents.onReceiveClick(cryptoCurrencyStatus, MainScreenAnalyticsEvent.ButtonReceive) + }, + onLongClick = { + clickIntents.onCopyAddressLongClick(cryptoCurrencyStatus) + }, + ), + WalletManageButton.Buy( + enabled = true, + dimContent = false, + onClick = { + clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId, screenType = VISA_TYPE) + }, + ), + ) + } } \ 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/BalancesAndLimitsBottomSheetConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt index ffb4722637..eb2e240d23 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/BalancesAndLimitsBottomSheetConverter.kt @@ -1,5 +1,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.DateTimeFormatters @@ -13,6 +15,7 @@ import java.math.BigDecimal internal class BalancesAndLimitsBottomSheetConverter( private val eventSender: WalletEventSender, + private val analyticsEventHandler: AnalyticsEventHandler, ) : Converter { override fun convert(value: VisaCurrency): BalancesAndLimitsBottomSheetConfig { @@ -28,23 +31,25 @@ internal class BalancesAndLimitsBottomSheetConverter( blockedBalance = value.balances.blocked.let(::formatAmount), debit = value.balances.debt.let(::formatAmount), amlVerified = value.balances.verified.let(::formatAmount), - onInfoClick = this::showBalanceInfo, + onInfoClick = this::balanceInfoOnClick, ), limit = BalancesAndLimitsBottomSheetConfig.Limit( availableBy = DateTimeFormatters.formatDate(date = value.limits.expirationDate), total = otpLimit, other = noOtpLimit, singleTransaction = value.limits.singleTransaction.let(::formatAmount), - onInfoClick = { showLimitInfo(otpLimit, noOtpLimit) }, + onInfoClick = { limitInfoOnClick(otpLimit, noOtpLimit) }, ), ) } - private fun showBalanceInfo() { + private fun balanceInfoOnClick() { + analyticsEventHandler.send(MainScreenAnalyticsEvent.NoticeBalancesInfo) eventSender.send(WalletEvent.ShowAlert(WalletAlertState.VisaBalancesInfo)) } - private fun showLimitInfo(totalLimit: String, otherLimit: String) { + private fun limitInfoOnClick(totalLimit: String, otherLimit: String) { + analyticsEventHandler.send(MainScreenAnalyticsEvent.NoticeLimitsInfo) eventSender.send(WalletEvent.ShowAlert(WalletAlertState.VisaLimitsInfo(totalLimit, otherLimit))) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt index dee6043f7c..99b787897e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/VisaTxDetailsBottomSheetConverter.kt @@ -24,6 +24,7 @@ internal class VisaTxDetailsBottomSheetConverter( return VisaTxDetailsBottomSheetConfig( transaction = createTransaction(value), requests = value.requests.map(::createRequest).toImmutableList(), + onDisputeClick = { clickIntents.onDisputeClick(value) }, ) } @@ -33,7 +34,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 +52,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..4cb5daf3c1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt @@ -1,15 +1,15 @@ package com.tangem.feature.wallet.presentation.wallet.state.utils +import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent.Companion.WALLET_TYPE +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.transactions.state.TxHistoryState -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 +22,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 +40,7 @@ internal class WalletLoadingStateFactory( warnings = persistentListOf(), bottomSheetConfig = null, tokensListState = WalletTokensListState.ContentState.Loading, + nftState = WalletNFTItemUM.Hidden, ) } @@ -67,7 +67,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 +76,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,13 +98,13 @@ 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( enabled = true, dimContent = false, - onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId) }, + onClick = { clickIntents.onMultiWalletBuyClick(userWalletId = userWallet.walletId, WALLET_TYPE) }, ), WalletManageButton.Swap( enabled = true, @@ -117,6 +119,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 - if (index == 0) { + } + + LazyColumn( + modifier = modifier.background(TangemTheme.colors.background.secondary), + contentPadding = PaddingValues( + bottom = TangemTheme.dimens.spacing16, + ), + verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { TransactionBlock(config.transaction) - } else { - BlockchainRequestBlock(config.requests[index - 1]) } - }, - ) + + items(config.requests) { item -> + BlockchainRequestBlock(item) + } + + item { + DisputeButton(config.onDisputeClick) + } + } + } } @Composable private fun TransactionBlock(transaction: VisaTxDetailsBottomSheetConfig.Transaction, modifier: Modifier = Modifier) { BlockContent( modifier = modifier, - title = stringReference(value = "Transaction"), + title = resourceReference(R.string.visa_transaction_details_title), content = { BlockItem( - title = stringReference(value = "Type"), + title = resourceReference(R.string.visa_transaction_details_type), value = transaction.type, ) BlockItem( - title = stringReference(value = "Status"), + title = resourceReference(R.string.visa_transaction_details_status), value = transaction.status, ) BlockItem( - title = stringReference(value = "Blockchain Amount"), + title = resourceReference(R.string.visa_transaction_details_blockchain_amount), value = transaction.blockchainAmount, ) BlockItem( - title = stringReference(value = "Blockchain Fee"), - value = transaction.blockchainFee, - ) - BlockItem( - title = stringReference(value = "Transaction Amount"), + title = resourceReference(R.string.visa_transaction_details_transaction_amount), value = transaction.transactionAmount, ) BlockItem( - title = stringReference(value = "Currency Code"), + title = resourceReference(R.string.visa_transaction_details_currency_code), value = transaction.transactionCurrencyCode, ) BlockItem( - title = stringReference(value = "Merchant Name"), + title = resourceReference(R.string.visa_transaction_details_merchant_name), value = transaction.merchantName, ) BlockItem( - title = stringReference(value = "Merchant City"), + title = resourceReference(R.string.visa_transaction_details_merchant_city), value = transaction.merchantCity, ) BlockItem( - title = stringReference(value = "Merchant Country Code"), + title = resourceReference(R.string.visa_transaction_details_merchant_country_code), value = transaction.merchantCountryCode, ) BlockItem( - title = stringReference(value = "Merchant Category Code"), + title = resourceReference(R.string.visa_transaction_details_merchant_category_code), value = transaction.merchantCategoryCode, ) }, @@ -112,7 +130,7 @@ private fun TransactionBlock(transaction: VisaTxDetailsBottomSheetConfig.Transac private fun BlockchainRequestBlock(request: VisaTxDetailsBottomSheetConfig.Request, modifier: Modifier = Modifier) { BlockContent( modifier = modifier, - title = stringReference(value = "Blockchain request"), + title = resourceReference(R.string.visa_transaction_details_transaction_request), description = { if (request.onExploreClick != null) { Row( @@ -127,7 +145,7 @@ private fun BlockchainRequestBlock(request: VisaTxDetailsBottomSheetConfig.Reque tint = TangemTheme.colors.icon.informative, ) Text( - text = "Explore", + text = stringResourceSafe(R.string.common_explore), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.caption1, ) @@ -137,81 +155,53 @@ private fun BlockchainRequestBlock(request: VisaTxDetailsBottomSheetConfig.Reque }, content = { BlockItem( - title = stringReference(value = "Type"), + title = resourceReference(R.string.visa_transaction_details_type), value = request.type, ) BlockItem( - title = stringReference(value = "Status"), + title = resourceReference(R.string.visa_transaction_details_status), value = request.status, ) BlockItem( - title = stringReference(value = "Blockchain Amount"), + title = resourceReference(R.string.visa_transaction_details_blockchain_amount), value = request.blockchainAmount, ) BlockItem( - title = stringReference(value = "Blockchain Fee"), - value = request.blockchainFee, - ) - BlockItem( - title = stringReference(value = "Transaction Amount"), + title = resourceReference(R.string.visa_transaction_details_transaction_amount), value = request.transactionAmount, ) BlockItem( - title = stringReference(value = "Currency Code"), + title = resourceReference(R.string.visa_transaction_details_currency_code), value = request.currencyCode, ) BlockItem( - title = stringReference(value = "Error Code"), + title = resourceReference(R.string.visa_transaction_details_error_code), value = request.errorCode.toString(), ) BlockItem( - title = stringReference(value = "Date"), + title = resourceReference(R.string.visa_transaction_details_date), value = request.date, ) BlockItem( - title = stringReference(value = "Tx Hash"), + title = resourceReference(R.string.visa_transaction_details_transaction_hash), value = request.txHash, ) BlockItem( - title = stringReference(value = "Tx Status"), + title = resourceReference(R.string.visa_transaction_details_transaction_status), value = request.txStatus, ) }, ) } -@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContentContainer( - blocksCount: Int, - title: @Composable BoxScope.() -> Unit, - block: @Composable ColumnScope.(Int) -> Unit, - modifier: Modifier = Modifier, -) { - LazyColumn( - modifier = modifier.background(TangemTheme.colors.background.secondary), - contentPadding = PaddingValues( - bottom = TangemTheme.dimens.spacing16, - ), - verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - stickyHeader { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = TangemTheme.dimens.size44) - .background(TangemTheme.colors.background.secondary), - contentAlignment = Alignment.Center, - content = title, - ) - } - items(blocksCount) { index -> - Column { - block(index) - } - } - } +private fun DisputeButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + SecondaryButtonIconStart( + modifier = modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.visa_tx_dispute_button), + iconResId = R.drawable.ic_alert_triangle_20, + onClick = onClick, + ) } // region Preview @@ -235,7 +225,6 @@ private class VisaTxDetailsBottomSheetParameterProvider : type = "payment", status = "authorized", blockchainAmount = "1.0614 USDT", - blockchainFee = "0.12", transactionAmount = "0.99 €", transactionCurrencyCode = "978", merchantName = "SQ *FORMATIVE", @@ -249,7 +238,6 @@ private class VisaTxDetailsBottomSheetParameterProvider : type = "authorize_payment", status = "accepted", blockchainAmount = "1.0593 USDT", - blockchainFee = "0.10", transactionAmount = "0.99 €", currencyCode = "978", errorCode = 0, @@ -258,21 +246,8 @@ private class VisaTxDetailsBottomSheetParameterProvider : txStatus = "confirmed", onExploreClick = {}, ), - VisaTxDetailsBottomSheetConfig.Request( - id = "524582128501966799", - type = "settlement", - status = "accepted", - blockchainAmount = "1.0614 USDT", - blockchainFee = "0.12", - transactionAmount = "0.99 €", - currencyCode = "978", - errorCode = 0, - date = "2023-12-01 00:01:00.000 +0300", - txHash = "0x635841d5fbdf1087cdd929019c863ee88a7165e4340bc17ddd0b1d04dfb11daa", - txStatus = "confirmed", - onExploreClick = {}, - ), ), + onDisputeClick = {}, ), ), ) diff --git a/features/wallet/impl/src/main/res/drawable/ill_keiro_card2_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_keiro_card2_120_106.webp new file mode 100644 index 0000000000..58ddd441a0 Binary files /dev/null and b/features/wallet/impl/src/main/res/drawable/ill_keiro_card2_120_106.webp differ diff --git a/features/wallet/impl/src/main/res/drawable/ill_keiro_card3_120_106.webp b/features/wallet/impl/src/main/res/drawable/ill_keiro_card3_120_106.webp new file mode 100644 index 0000000000..371231a672 Binary files /dev/null and b/features/wallet/impl/src/main/res/drawable/ill_keiro_card3_120_106.webp differ diff --git a/features/walletconnect/api/.gitignore b/features/walletconnect/api/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/features/walletconnect/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/features/walletconnect/api/build.gradle.kts b/features/walletconnect/api/build.gradle.kts new file mode 100644 index 0000000000..9eea21d4c0 --- /dev/null +++ b/features/walletconnect/api/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.walletconnect.api" +} + +dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain models */ + implementation(projects.domain.wallets.models) +} \ No newline at end of file diff --git a/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectEntryComponent.kt b/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectEntryComponent.kt new file mode 100644 index 0000000000..c7d60d1091 --- /dev/null +++ b/features/walletconnect/api/src/main/kotlin/com/tangem/features/walletconnect/components/WalletConnectEntryComponent.kt @@ -0,0 +1,8 @@ +package com.tangem.features.walletconnect.components + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface WalletConnectEntryComponent : ComposableContentComponent { + interface Factory : ComponentFactory +} \ 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..3f1defd17d 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" @@ -102,6 +102,7 @@ espresso-intents = "3.5.1" junit = "4.13.2" junitAndroidExt = "1.1.5" mockk = "1.13.4" +turbine = "1.2.0" truth = "1.1.3" ultron-android = "2.5.4" ultron-compose = "2.5.4" @@ -199,6 +200,7 @@ test-junit = { module = "junit:junit", version.ref = "junit" } test-junit-android = { module = "androidx.test.ext:junit", version.ref = "junitAndroidExt" } test-truth = { module = "com.google.truth:truth", version.ref = "truth" } test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +test-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } test-ultron-android = { module = "com.atiurin:ultron-android", version.ref = "ultron-android" } test-ultron-compose = { module = "com.atiurin:ultron-compose", version.ref = "ultron-compose" } test-ultron-allure = { module = "com.atiurin:ultron-allure", version.ref = "ultron-allure" } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index e6912e07bc..884ee07a79 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -1,9 +1,15 @@ [versions] -tangemBlockchainSdk = "develop-1009" +# Blockchain SDK, Card SDK and Vico artifacts are built in a private namespace and then published to a +# public Maven repository, so anyone can build the app from source +# https://github.com/tangem/blockchain-sdk-kotlin/packages/ +# https://github.com/tangem/tangem-sdk-android/ +# https://github.com/tangem/vico + +tangemBlockchainSdk = "develop-1014" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-451" +tangemCardSdk = "develop-455" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ -tangemVico = "2.0.0-alpha.25-tangem-developments8" +tangemVico = "2.0.0-alpha.25-tangem12" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ 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..71205a6291 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -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 @@ -73,6 +72,11 @@ object BlockchainUtils { return blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet } + fun isTon(blockchainId: String): Boolean { + val blockchain = Blockchain.fromId(blockchainId) + return blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet + } + fun isSupportedNetworkId(blockchainId: String, excludedBlockchains: ExcludedBlockchains): Boolean { val blockchain = Blockchain.fromNetworkId(blockchainId) @@ -125,7 +129,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..e0a1deedfc --- /dev/null +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt @@ -0,0 +1,24 @@ +package com.tangem.sdk.api.di + +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.operations.attestation.CardArtworksProvider +import com.tangem.operations.attestation.OnlineCardVerifier +import dagger.Module +import dagger.Provides +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() + + @Provides + @Singleton + fun provideCardArtworksProvider(sdkRepository: CardSdkConfigRepository): CardArtworksProvider = + CardArtworksProvider(sdkRepository.sdk.config.isTangemAttestationProdEnv) +} \ No newline at end of file diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt new file mode 100644 index 0000000000..7eb52af432 --- /dev/null +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/CardSdkFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.sdk.api.featuretoggles + +/** + * Card SDK feature toggles + * +[REDACTED_AUTHOR] + */ +interface CardSdkFeatureToggles { + + val isNewAttestationEnabled: Boolean + + val isNewArtworkLoadingEnabled: Boolean +} \ No newline at end of file diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt new file mode 100644 index 0000000000..0a12b3ce37 --- /dev/null +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/featuretoggles/DefaultCardSdkFeatureToggles.kt @@ -0,0 +1,15 @@ +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") + + override val isNewArtworkLoadingEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "NEW_ARTWORK_LOADING") +} \ 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..3f7384a646 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,11 @@ 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") +include(":domain:quotes") // endregion Domain modules // region Data modules @@ -300,7 +312,8 @@ 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 +include(":data:quotes") +// endregion Data modules \ No newline at end of file