diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3f7a4fcde..de5864d8b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.transaction) + implementation(projects.domain.analytics) implementation(projects.common) implementation(projects.core.analytics) @@ -74,6 +75,7 @@ dependencies { implementation(projects.data.tokens) implementation(projects.data.txhistory) implementation(projects.data.wallets) + implementation(projects.data.analytics) /** Features */ implementation(projects.features.onboarding) diff --git a/app/src/main/java/com/tangem/tap/TapApplication.kt b/app/src/main/java/com/tangem/tap/TapApplication.kt index 420a6ff091..0aecd2719a 100644 --- a/app/src/main/java/com/tangem/tap/TapApplication.kt +++ b/app/src/main/java/com/tangem/tap/TapApplication.kt @@ -14,6 +14,7 @@ import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder import com.tangem.core.analytics.Analytics +import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.core.featuretoggle.manager.FeatureTogglesManager import com.tangem.data.source.preferences.PreferencesDataSource import com.tangem.datasource.api.common.MoshiConverter @@ -216,6 +217,9 @@ internal class TapApplication : Application(), ImageLoaderFactory { @Inject lateinit var sendFeatureToggles: SendFeatureToggles + + @Inject + lateinit var oneTimeEventFilter: OneTimeEventFilter // endregion Injected override fun onCreate() { @@ -353,6 +357,8 @@ internal class TapApplication : Application(), ImageLoaderFactory { factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder()) factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder()) + factory.addFilter(oneTimeEventFilter) + val buildData = AnalyticsHandlerBuilder.Data( application = application, config = config, 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 1579ca569f..11af89cb89 100644 --- a/app/src/main/java/com/tangem/tap/common/DialogManager.kt +++ b/app/src/main/java/com/tangem/tap/common/DialogManager.kt @@ -65,15 +65,16 @@ class DialogManager : StoreSubscriber { messageRes = R.string.wallet_connect_scanner_error_not_valid_card, context = context, ) - is WalletConnectDialog.AddNetwork -> + is WalletConnectDialog.AddNetwork -> { + val message = context.getString( + R.string.wallet_connect_error_missing_blockchains, + ) + state.dialog.networks.joinToString() SimpleAlertDialog.create( titleRes = R.string.wallet_connect_title, - message = context.getString( - R.string.wallet_connect_network_not_found_format, - state.dialog.network, - ), + message = message, context = context, ) + } is WalletConnectDialog.OpeningSessionRejected -> { SimpleAlertDialog.create( titleRes = R.string.wallet_connect_title, diff --git a/app/src/main/java/com/tangem/tap/common/analytics/converters/TopUpEventConverter.kt b/app/src/main/java/com/tangem/tap/common/analytics/converters/TopUpEventConverter.kt index 429b2e5ebc..ba62707b7f 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/converters/TopUpEventConverter.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/converters/TopUpEventConverter.kt @@ -2,16 +2,18 @@ package com.tangem.tap.common.analytics.converters import com.tangem.common.Converter import com.tangem.domain.common.CardTypesResolver +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.tap.common.analytics.events.Basic /** [REDACTED_AUTHOR] */ -class TopUpEventConverter : Converter { +class TopUpEventConverter : Converter, Basic.ToppedUp?> { - override fun convert(value: CardTypesResolver): Basic.ToppedUp? { - val paramCardCurrency = ParamCardCurrencyConverter().convert(value) ?: return null + override fun convert(value: Pair): Basic.ToppedUp? { + val (userWalletId, resolver) = value + val paramCardCurrency = ParamCardCurrencyConverter().convert(resolver) ?: return null - return Basic.ToppedUp(paramCardCurrency) + return Basic.ToppedUp(userWalletId, paramCardCurrency) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/AnalyticsParam.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/AnalyticsParam.kt index 2d1bd09138..e3195c7e34 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/AnalyticsParam.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/AnalyticsParam.kt @@ -1,5 +1,6 @@ package com.tangem.tap.common.analytics.events +import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.tap.features.details.redux.SecurityOption sealed class AnalyticsParam { @@ -14,7 +15,7 @@ sealed class AnalyticsParam { // MultiCurrency or CurrencyType sealed class CardCurrency(val value: String) { - object MultiCurrency : CardCurrency("Multicurrency") + object MultiCurrency : CardCurrency(value = "Multicurrency") class SingleCurrency(type: CurrencyType) : CardCurrency(type.value) } @@ -139,6 +140,22 @@ sealed class AnalyticsParam { object SeedImport : WalletCreationType(value = "Seed Import") } + sealed class AppTheme(val value: String) { + object System : AppTheme("System") + object Dark : AppTheme("Dark") + object Light : AppTheme("Light") + + companion object { + fun fromAppThemeMode(mode: AppThemeMode): AppTheme { + return when (mode) { + AppThemeMode.FORCE_DARK -> Dark + AppThemeMode.FORCE_LIGHT -> Light + AppThemeMode.FOLLOW_SYSTEM -> System + } + } + } + } + companion object Key { const val BLOCKCHAIN = "blockchain" const val TOKEN = "Token" diff --git a/app/src/main/java/com/tangem/tap/common/analytics/events/Basic.kt b/app/src/main/java/com/tangem/tap/common/analytics/events/Basic.kt index ade60b95cf..812527539e 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/events/Basic.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/events/Basic.kt @@ -1,6 +1,8 @@ package com.tangem.tap.common.analytics.events import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.OneTimeAnalyticsEvent +import com.tangem.domain.wallets.models.UserWalletId /** [REDACTED_AUTHOR] @@ -50,10 +52,15 @@ sealed class Basic( } } - class ToppedUp(currency: AnalyticsParam.CardCurrency) : Basic( - event = "Topped up", - params = mapOf(AnalyticsParam.CURRENCY to currency.value), - ) + class ToppedUp(userWalletId: UserWalletId, currency: AnalyticsParam.CardCurrency) : + Basic( + event = "Topped up", + params = mapOf(AnalyticsParam.CURRENCY to currency.value), + ), + OneTimeAnalyticsEvent { + + override val oneTimeEventId: String = id + userWalletId.stringValue + } class TransactionSent(sentFrom: AnalyticsParam.TxSentFrom, memoType: MemoType) : Basic( event = "Transaction sent", 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 772c85c8b1..b2123614eb 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 @@ -84,5 +84,10 @@ sealed class Settings( event = "Main Currency Changed", params = mapOf("Currency Type" to currencyType), ) + + class ThemeSwitched(theme: AnalyticsParam.AppTheme) : AppSettings( + event = "App Theme Switched", + params = mapOf("State" to theme.value), + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt index a1daeb7ccf..5eed564c97 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt @@ -9,8 +9,8 @@ class AmplitudeAnalyticsHandler( override fun id(): String = ID - override fun send(event: String, params: Map) { - client.logEvent(event, params) + override fun send(eventId: String, params: Map) { + client.logEvent(eventId, params) } companion object { diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsFlyer/AppsFlyerAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsFlyer/AppsFlyerAnalyticsHandler.kt index c4b12fc278..710c7308e6 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsFlyer/AppsFlyerAnalyticsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/appsFlyer/AppsFlyerAnalyticsHandler.kt @@ -12,8 +12,8 @@ class AppsFlyerAnalyticsHandler( override fun id(): String = ID - override fun send(event: String, params: Map) { - client.logEvent(event, params) + override fun send(eventId: String, params: Map) { + client.logEvent(eventId, params) } override fun send(event: AnalyticsEvent) { 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 e445597e8f..10b399382c 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 @@ -14,8 +14,8 @@ class FirebaseAnalyticsHandler( override fun id(): String = ID - override fun send(event: String, params: Map) { - client.logEvent(event, params) + override fun send(eventId: String, params: Map) { + client.logEvent(eventId, params) } override fun send(event: AnalyticsEvent) { diff --git a/app/src/main/java/com/tangem/tap/common/analytics/topup/TopUpController.kt b/app/src/main/java/com/tangem/tap/common/analytics/topup/TopUpController.kt index b32af636df..376143ec6b 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/topup/TopUpController.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/topup/TopUpController.kt @@ -154,7 +154,7 @@ class TopUpController( if (cardBalanceState.isToppedUp()) { topupWalletStorage.save(topupInfo.copy(cardBalanceState = DataSourceTopupInfo.CardBalanceState.Full)) - TopUpEventConverter().convert(cardTypesResolver)?.let { + TopUpEventConverter().convert(value = userWalletId to cardTypesResolver)?.let { Analytics.send(it) } } diff --git a/app/src/main/java/com/tangem/tap/di/domain/AnalyticsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AnalyticsDomainModule.kt new file mode 100644 index 0000000000..dda9f2c253 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/AnalyticsDomainModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.analytics.CheckIsWalletToppedUpUseCase +import com.tangem.domain.analytics.repository.AnalyticsRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +internal object AnalyticsDomainModule { + + @Provides + fun provideCheckIsWalletToppedUpUseCase(analyticsRepository: AnalyticsRepository): CheckIsWalletToppedUpUseCase { + return CheckIsWalletToppedUpUseCase(analyticsRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt index 17782946ce..1c596a4b4d 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SettingsDomainModule.kt @@ -91,4 +91,16 @@ internal object SettingsDomainModule { ): UpdateBalanceHidingSettingsUseCase { return UpdateBalanceHidingSettingsUseCase(balanceHidingRepository) } + + @Provides + @ViewModelScoped + fun provideSetWalletsScrollPreviewIsShown(settingsRepository: SettingsRepository): NeverToShowWalletsScrollPreview { + return NeverToShowWalletsScrollPreview(settingsRepository = settingsRepository) + } + + @Provides + @ViewModelScoped + fun provideIsWalletsScrollPreviewEnabled(settingsRepository: SettingsRepository): IsWalletsScrollPreviewEnabled { + return IsWalletsScrollPreviewEnabled(settingsRepository = settingsRepository) + } } \ 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 d66c81c65f..d4488fe919 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,10 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.tokens.* -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository -import com.tangem.domain.tokens.repository.NetworksRepository -import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -231,4 +228,10 @@ internal object TokensDomainModule { ): GetMissedAddressesCryptoCurrenciesUseCase { return GetMissedAddressesCryptoCurrenciesUseCase(currenciesRepository = currenciesRepository) } + + @Provides + @ViewModelScoped + fun provideGetGlobalTokenListUseCase(tokensListRepository: TokensListRepository): GetGlobalTokenListUseCase { + return GetGlobalTokenListUseCase(repository = tokensListRepository) + } } \ 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 5b68512d77..ead5fca293 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 @@ -1,6 +1,9 @@ package com.tangem.tap.di.domain +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.demo.IsDemoCardUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -15,10 +18,24 @@ internal object TransactionDomainModule { @Provides @ViewModelScoped - fun provideGetUseCase( + fun provideGetFeeUseCase( walletManagersFacade: WalletManagersFacade, dispatchers: CoroutineDispatcherProvider, ): GetFeeUseCase { return GetFeeUseCase(walletManagersFacade, dispatchers) } + + @Provides + @ViewModelScoped + fun provideSendTransactionUseCase( + isDemoCardUseCase: IsDemoCardUseCase, + walletManagersFacade: WalletManagersFacade, + cardSdkConfigRepository: CardSdkConfigRepository, + ): SendTransactionUseCase { + return SendTransactionUseCase( + isDemoCardUseCase = isDemoCardUseCase, + cardSdkConfigRepository = cardSdkConfigRepository, + walletManagersFacade = walletManagersFacade, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManager.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManager.kt index af472b3608..c285a15c80 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/BiometricUserWalletsListManager.kt @@ -67,15 +67,17 @@ internal class BiometricUserWalletsListManager( } } .map { - if (throwIfNotAllWalletsUnlocked && state.value.userWallets.any(UserWallet::isLocked)) { - Timber.e("Not all user wallets have been unlocked") + val userWallets = state.value.userWallets + + if (throwIfNotAllWalletsUnlocked && userWallets.any(UserWallet::isLocked)) { + Timber.e("Some user wallets remain locked") throw UserWalletsListError.NotAllUserWalletsUnlocked } val selectedUserWallet = selectedUserWalletSync if (selectedUserWallet == null || selectedUserWallet.isLocked) { - Timber.e("Unable to find selected user wallet") - throw UserWalletsListError.NoUserWalletSelected + findAndSetUnlockedUserWallet(userWallets) + ?: throw UserWalletsListError.NoUserWalletSelected } else { selectedUserWallet } @@ -280,13 +282,14 @@ internal class BiometricUserWalletsListManager( prevSelectedWalletId: UserWalletId?, userWallets: List, ): UserWalletId? { - val findUnlockedAndSet = { - userWallets.firstOrNull { !it.isLocked } - ?.walletId - ?.also { selectedUserWalletRepository.set(it) } - } + return prevSelectedWalletId + ?: (selectedUserWalletRepository.get() ?: findAndSetUnlockedUserWallet(userWallets)?.walletId) + } - return prevSelectedWalletId ?: (selectedUserWalletRepository.get() ?: findUnlockedAndSet()) + private fun findAndSetUnlockedUserWallet(userWallets: List): UserWallet? { + return userWallets + .firstOrNull { !it.isLocked } + ?.also { selectedUserWalletRepository.set(it.walletId) } } private fun changeSelectedUserWalletIdIfNeeded(walletsIdsToRemove: List) { diff --git a/app/src/main/java/com/tangem/tap/features/BaseFragment.kt b/app/src/main/java/com/tangem/tap/features/BaseFragment.kt index e7aa8ff258..2c5ddc63f2 100644 --- a/app/src/main/java/com/tangem/tap/features/BaseFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/BaseFragment.kt @@ -75,9 +75,10 @@ interface FragmentOnBackPressedHandler { @SuppressLint("FragmentBackPressedCallback") fun Fragment.addBackPressHandler(handler: FragmentOnBackPressedHandler) { - requireActivity().onBackPressedDispatcher.addCallback { - handler.handleOnBackPressed() - } + requireActivity().onBackPressedDispatcher.addCallback( + owner = this, + onBackPressed = { handler.handleOnBackPressed() }, + ) view?.findViewById(R.id.toolbar)?.setNavigationOnClickListener { handler.handleOnBackPressed() 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 3b96ded384..3044d21544 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 @@ -278,7 +278,7 @@ class WalletConnectMiddleware { ).guard { store.dispatchOnMain( GlobalAction.ShowDialog( - WalletConnectDialog.AddNetwork(blockchain.fullName), + WalletConnectDialog.AddNetwork(listOf(blockchain.fullName)), ), ) return@launch @@ -325,7 +325,7 @@ class WalletConnectMiddleware { is WalletConnectError.ApprovalErrorAddNetwork -> { store.dispatchOnMain( GlobalAction.ShowDialog( - WalletConnectDialog.UnsupportedNetwork(action.error.networks), + WalletConnectDialog.AddNetwork(action.error.networks), ), ) } @@ -417,7 +417,7 @@ class WalletConnectMiddleware { store.dispatchOnMain(WalletConnectAction.FailureEstablishingSession(session.session)) store.dispatchOnMain( GlobalAction.ShowDialog( - WalletConnectDialog.AddNetwork(blockchain.fullName), + WalletConnectDialog.AddNetwork(listOf(blockchain.fullName)), ), ) return 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 93f000c3d5..e9354c13a7 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 @@ -92,7 +92,7 @@ sealed class WalletConnectDialog : StateDialog { data class ClipboardOrScanQr(val clipboardUri: String) : WalletConnectDialog() object UnsupportedCard : WalletConnectDialog() data class UnsupportedNetwork(val networks: List? = null) : WalletConnectDialog() - data class AddNetwork(val network: String) : WalletConnectDialog() + data class AddNetwork(val networks: List) : WalletConnectDialog() object OpeningSessionRejected : WalletConnectDialog() object SessionTimeout : WalletConnectDialog() data class ApproveWcSession( diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsViewModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsViewModel.kt index b7a081674b..3bebecb90a 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsViewModel.kt @@ -4,10 +4,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.LifecycleCoroutineScope +import com.tangem.core.analytics.Analytics import com.tangem.core.navigation.AppScreen import com.tangem.core.navigation.NavigationAction import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.model.AppThemeMode +import com.tangem.tap.common.analytics.events.AnalyticsParam +import com.tangem.tap.common.analytics.events.Settings import com.tangem.tap.common.entities.FiatCurrency import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.dispatchWithMain @@ -121,6 +124,11 @@ internal class AppSettingsViewModel( dialog = dialogsFactory.createThemeModeSelectorDialog( selectedModeIndex = selectedMode.ordinal, onSelect = { mode -> + Analytics.send( + event = Settings.AppSettings.ThemeSwitched( + theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode), + ), + ) store.dispatchOnMain(DetailsAction.AppSettings.ChangeAppThemeMode(mode)) dismissDialog() }, diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSwitchItem.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSwitchItem.kt index 74815a675f..e4198a521c 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSwitchItem.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSwitchItem.kt @@ -14,11 +14,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.core.ui.components.SpacerH4 import com.tangem.core.ui.components.SpacerW32 +import com.tangem.core.ui.components.TangemSwitch import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.tap.features.details.ui.appsettings.AppSettingsItemsFactory import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Item -import com.tangem.core.ui.components.TangemSwitch @Composable internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier) { @@ -63,6 +63,7 @@ internal fun SettingsSwitchItem(item: Item.Switch, modifier: Modifier = Modifier checked = item.isChecked, enabled = item.isEnabled, onCheckedChange = item.onCheckedChange, + checkedColor = TangemTheme.colors.icon.accent, ) } } 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 947a437152..2f455c7eae 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 @@ -11,7 +11,10 @@ import com.tangem.domain.common.util.twinsIsTwinned import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.userwallets.UserWalletBuilder +import com.tangem.domain.userwallets.UserWalletIdBuilder import com.tangem.tap.* +import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter +import com.tangem.tap.common.analytics.events.Basic import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.onUserWalletSelected import com.tangem.tap.common.extensions.removeContext @@ -117,6 +120,15 @@ object OnboardingHelper { Analytics.removeContext() } + fun sendToppedUpEvent(scanResponse: ScanResponse) { + val userWalletId = UserWalletIdBuilder.scanResponse(scanResponse).build() + val currency = ParamCardCurrencyConverter().convert(scanResponse.cardTypesResolver) + + if (userWalletId != null && currency != null) { + Analytics.send(Basic.ToppedUp(userWalletId, currency)) + } + } + private suspend fun proceedWithScanResponse(scanResponse: ScanResponse, backupCardsIds: List?) { val userWallet = UserWalletBuilder(scanResponse) .backupCardsIds(backupCardsIds?.toSet()) diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/note/redux/OnboardingNoteMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/note/redux/OnboardingNoteMiddleware.kt index 675eb82581..266b627b65 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/note/redux/OnboardingNoteMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/note/redux/OnboardingNoteMiddleware.kt @@ -152,6 +152,8 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch } is OnboardingNoteAction.Balance.Set -> { if (action.balance.balanceIsToppedUp()) { + OnboardingHelper.sendToppedUpEvent(scanResponse) + store.state.globalState.topUpController?.send(scanResponse, AnalyticsParam.CardBalanceState.Full) store.dispatch(OnboardingNoteAction.SetStepOfScreen(OnboardingNoteStep.Done)) } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/redux/TwinCardsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/redux/TwinCardsMiddleware.kt index 441798d55f..31072fcd23 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/redux/TwinCardsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/twins/redux/TwinCardsMiddleware.kt @@ -260,6 +260,8 @@ private fun handle(action: Action, dispatch: DispatchFunction) { } is TwinCardsAction.Balance.Set -> { if (action.balance.balanceIsToppedUp()) { + OnboardingHelper.sendToppedUpEvent(getScanResponse()) + store.state.globalState.topUpController?.send(getScanResponse(), AnalyticsParam.CardBalanceState.Full) store.dispatchOnMain(TwinCardsAction.SetStepOfScreen(TwinCardsStep.Done)) } diff --git a/app/src/main/java/com/tangem/tap/features/tokens/legacy/redux/TokensMiddleware.kt b/app/src/main/java/com/tangem/tap/features/tokens/legacy/redux/TokensMiddleware.kt index 1ec868ce0e..101c7c51e5 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/legacy/redux/TokensMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/legacy/redux/TokensMiddleware.kt @@ -20,6 +20,8 @@ import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase import com.tangem.domain.tokens.TokenWithBlockchain import com.tangem.domain.tokens.TokensAction import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.walletconnect.WalletConnectActions +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.operations.derivation.ExtendedPublicKeysMap import com.tangem.tap.* @@ -90,13 +92,17 @@ object TokensMiddleware { if (scanResponse.supportsHdWallet()) { deriveMissingCoins(scanResponse = scanResponse, currencyList = currencyList) { submitNewAdd( - userWalletId = action.userWallet.walletId, + userWallet = action.userWallet, updatedScanResponse = it, currencyList = currencyList, ) } } else { - submitNewAdd(userWalletId = action.userWallet.walletId, scanResponse, currencyList = currencyList) + submitNewAdd( + userWallet = action.userWallet, + updatedScanResponse = scanResponse, + currencyList = currencyList, + ) } } } @@ -375,16 +381,18 @@ object TokensMiddleware { } private fun submitNewAdd( - userWalletId: UserWalletId, + userWallet: UserWallet, updatedScanResponse: ScanResponse, currencyList: List, ) { scope.launch { userWalletsListManager.update( - userWalletId = userWalletId, + userWalletId = userWallet.walletId, update = { it.copy(scanResponse = updatedScanResponse) }, ).doOnSuccess { - addCryptoCurrenciesUseCase(userWalletId, currencyList) + addCryptoCurrenciesUseCase(userWallet.walletId, currencyList).onRight { + store.dispatch(action = WalletConnectActions.New.SetupUserChains(userWallet = userWallet)) + } } } store.dispatchOnMain(NavigationAction.PopBackTo()) diff --git a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt index 33a23f9750..3864885ded 100644 --- a/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/wallet/redux/middlewares/TradeCryptoMiddleware.kt @@ -9,11 +9,8 @@ import com.tangem.common.extensions.guard import com.tangem.core.analytics.Analytics import com.tangem.core.navigation.AppScreen import com.tangem.core.navigation.NavigationAction -import com.tangem.domain.common.extensions.toCoinId -import com.tangem.domain.common.extensions.toNetworkId import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.NetworkAddress import com.tangem.feature.swap.presentation.SwapFragment import com.tangem.features.send.api.navigation.SendRouter @@ -26,7 +23,6 @@ import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.dispatchOpenUrl import com.tangem.tap.common.redux.AppState import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.tokens.getIconUrl import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE import com.tangem.tap.features.send.redux.PrepareSendScreen @@ -41,9 +37,6 @@ import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope import com.tangem.tap.store import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import com.tangem.feature.swap.domain.models.domain.Currency as SwapCurrency @Suppress("LargeClass") class TradeCryptoMiddleware { @@ -58,17 +51,12 @@ class TradeCryptoMiddleware { is TradeCryptoAction.SendCrypto -> preconfigureAndOpenSendScreen(action) is TradeCryptoAction.FinishSelling -> openReceiptUrl(action.transactionId) is TradeCryptoAction.Swap -> { - openSwap( - currency = store.state.walletState.selectedWalletData?.currency?.toSwapCurrency(), - derivationPath = store.state.walletState.selectedWalletData?.currency?.derivationPath, - ) + // todo remove old flow } is TradeCryptoAction.New.Buy -> proceedNewBuyAction(state, action) is TradeCryptoAction.New.Sell -> proceedNewSellAction(action) is TradeCryptoAction.New.Swap -> openSwap( - currency = action.cryptoCurrency.toSwapCurrency(), - derivationPath = action.cryptoCurrency.network.derivationPath.value, - network = action.cryptoCurrency.network, + currency = action.cryptoCurrency, ) is TradeCryptoAction.New.SendToken -> handleNewSendToken(action = action) is TradeCryptoAction.New.SendCoin -> handleNewSendCoin(action = action) @@ -274,70 +262,14 @@ class TradeCryptoMiddleware { )?.let { store.dispatchOpenUrl(it) } } - private fun openSwap(currency: SwapCurrency?, derivationPath: String?, network: Network? = null) { + private fun openSwap(currency: CryptoCurrency) { val bundle = bundleOf( - SwapFragment.CURRENCY_BUNDLE_KEY to Json.encodeToString(currency), - SwapFragment.DERIVATION_PATH to derivationPath, - SwapFragment.NETWORK to network, + SwapFragment.CURRENCY_BUNDLE_KEY to currency, ) store.dispatchOnMain(NavigationAction.NavigateTo(screen = AppScreen.Swap, bundle = bundle)) } - private fun CryptoCurrency.toSwapCurrency(): SwapCurrency { - val blockchain = Blockchain.fromId(network.id.value) - - return when (this) { - is CryptoCurrency.Coin -> { - SwapCurrency.NativeToken( - id = blockchain.toCoinId(), - name = name, - symbol = symbol, - networkId = blockchain.toNetworkId(), - // no need to set logoUrl for blockchain cause - // error when form url with coinId, coinId of eth and arbitrum the same - logoUrl = "", - ) - } - is CryptoCurrency.Token -> { - SwapCurrency.NonNativeToken( - id = id.rawCurrencyId ?: "", - name = name, - symbol = symbol, - networkId = blockchain.toNetworkId(), - logoUrl = getIconUrl(id.rawCurrencyId ?: ""), - contractAddress = contractAddress, - decimalCount = decimals, - ) - } - } - } - - private fun Currency.toSwapCurrency(): SwapCurrency { - return when (this) { - is Currency.Blockchain -> { - SwapCurrency.NativeToken( - id = blockchain.toCoinId(), - name = this.currencyName, - symbol = this.currencySymbol, - networkId = this.blockchain.toNetworkId(), - // no need to set logoUrl for blockchain cause - // error when form url with coinId, coinId of eth and arbitrum the same - logoUrl = "", - ) - } - is Currency.Token -> SwapCurrency.NonNativeToken( - id = this.token.id ?: "", - name = this.currencyName, - symbol = this.currencySymbol, - networkId = this.blockchain.toNetworkId(), - logoUrl = getIconUrl(this.token.id ?: ""), - contractAddress = this.token.contractAddress, - decimalCount = decimals, - ) - } - } - private fun handleNewSendToken(action: TradeCryptoAction.New.SendToken) { val currency = action.tokenCurrency val blockchain = Blockchain.fromId(currency.network.id.value) diff --git a/app/src/main/java/com/tangem/tap/network/auth/ExpressAuthProviderImpl.kt b/app/src/main/java/com/tangem/tap/network/auth/ExpressAuthProviderImpl.kt new file mode 100644 index 0000000000..2a7f65eee3 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/network/auth/ExpressAuthProviderImpl.kt @@ -0,0 +1,31 @@ +package com.tangem.tap.network.auth + +import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.local.userwallet.UserWalletsStore +import com.tangem.lib.auth.ExpressAuthProvider +import com.tangem.lib.auth.sessionId.ExpressSessionIdGenerator +import java.util.UUID + +class ExpressAuthProviderImpl( + private val userWalletsStore: UserWalletsStore, + private val configManager: ConfigManager, +) : ExpressAuthProvider, ExpressSessionIdGenerator { + + private var uuid = UUID.randomUUID() + + override fun getApiKey(): String { + return configManager.config.tangemExpressApiKey + } + + override fun getUserId(): String { + return userWalletsStore.selectedUserWalletOrNull?.walletId?.stringValue ?: "" + } + + override fun getSessionId(): String { + return uuid.toString() + } + + override fun generateNewSessionId() { + uuid = UUID.randomUUID() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt index b05033931a..33823b5d36 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -224,20 +224,18 @@ class TransactionManagerImpl( // for not EVM blockchains set gasLimit ZERO for now when (fee.data) { is TransactionFee.Single -> { - val fee = (fee.data as TransactionFee.Single).normal + val normalFee = (fee.data as TransactionFee.Single).normal val singleFee = ProxyFee( gasLimit = BigInteger.ZERO, - fee = convertToProxyAmount(amount = fee.amount), + fee = convertToProxyAmount(amount = normalFee.amount), ) - ProxyFees( - minFee = singleFee, - normalFee = singleFee, - priorityFee = singleFee, + ProxyFees.SingleFee( + singleFee = singleFee, ) } is TransactionFee.Choosable -> { val choosableFee = fee.data as TransactionFee.Choosable - ProxyFees( + ProxyFees.MultipleFees( minFee = ProxyFee( gasLimit = BigInteger.ZERO, fee = convertToProxyAmount(amount = choosableFee.minimum.amount), @@ -280,7 +278,7 @@ class TransactionManagerImpl( ).increaseBigIntegerByPercents(increaseBy) return when (val gasPrice = walletManager.getGasPrice()) { is Result.Success -> { - createProxyFees(gasPrice = gasPrice.data, gasLimit = gasLimit, blockchain = blockchain) + createMultipleProxyFees(gasPrice = gasPrice.data, gasLimit = gasLimit, blockchain = blockchain) } is Result.Failure -> { error(gasPrice.error.message ?: gasPrice.error.customMessage) @@ -316,7 +314,7 @@ class TransactionManagerImpl( fee = convertToProxyAmount(amount = choosableFee.priority.amount), ) - ProxyFees( + ProxyFees.MultipleFees( minFee = minProxyFee, normalFee = normalProxyFee, priorityFee = priorityProxyFee, @@ -456,7 +454,7 @@ class TransactionManagerImpl( * @param gasLimit * @param blockchain */ - private fun createProxyFees(gasPrice: BigInteger, gasLimit: BigInteger, blockchain: Blockchain): ProxyFees { + private fun createMultipleProxyFees(gasPrice: BigInteger, gasLimit: BigInteger, blockchain: Blockchain): ProxyFees { val gasPriceNormal = gasPrice.increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_NORMAL_FEE) val gasPricePriority = gasPrice.increaseBigIntegerByPercents(MULTIPLIER_GAS_PRICE_FOR_PRIORITY_FEE) val feeMin = gasLimit.multiply(gasPrice).toBigDecimal( @@ -495,7 +493,7 @@ class TransactionManagerImpl( decimals = blockchain.decimals(), ), ) - return ProxyFees( + return ProxyFees.MultipleFees( minFee = minFee, normalFee = normalFee, priorityFee = priorityFee, diff --git a/app/src/main/res/layout/layout_send_receipt.xml b/app/src/main/res/layout/layout_send_receipt.xml index 4a8fab6816..97e7afbb0f 100644 --- a/app/src/main/res/layout/layout_send_receipt.xml +++ b/app/src/main/res/layout/layout_send_receipt.xml @@ -33,7 +33,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:text="@string/send_fee_label" + android:text="@string/common_fee_label" android:textColor="@color/text_tertiary" android:textStyle="bold" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/layout_single_wallet_balance.xml b/app/src/main/res/layout/layout_single_wallet_balance.xml index c3a75693bc..d3c6de4ff3 100644 --- a/app/src/main/res/layout/layout_single_wallet_balance.xml +++ b/app/src/main/res/layout/layout_single_wallet_balance.xml @@ -21,7 +21,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:text="@string/onboarding_balance_title" + android:text="@string/common_balance_title" android:textColor="@color/text_tertiary" android:textSize="14sp" android:textStyle="bold" diff --git a/app/src/main/res/layout/view_onboarding_tv_balance.xml b/app/src/main/res/layout/view_onboarding_tv_balance.xml index f421e4d992..f84e954980 100644 --- a/app/src/main/res/layout/view_onboarding_tv_balance.xml +++ b/app/src/main/res/layout/view_onboarding_tv_balance.xml @@ -12,7 +12,7 @@ android:elevation="0dp" android:fontFamily="sans-serif-medium" android:letterSpacing="0.036" - android:text="@string/onboarding_balance_title" + android:text="@string/common_balance_title" android:textAllCaps="true" android:textColor="@color/text_secondary" android:textSize="14sp" diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index 29b4cfb591..9d05970191 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -1,19 +1,24 @@ plugins { - alias(deps.plugins.android.library) - alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.jvm) alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.hilt.android) id("configuration") } dependencies { /** DI */ - implementation(deps.hilt.android) + implementation(deps.hilt.core) kapt(deps.hilt.kapt) - /** Core shouldn't depends on core, but in case with utils and logging its necessary */ - implementation(projects.core.utils) - + /** Analytics - Models */ implementation(projects.core.analytics.models) + + /** Domain */ + implementation(projects.domain.analytics) + + /** Other */ + implementation(deps.kotlin.coroutines) + + /** Core shouldn't depend on core, but in case with utils and logging its necessary */ + implementation(projects.core.utils) } \ 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 e60b14d1ee..025a6c1038 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 @@ -8,4 +8,7 @@ open class AnalyticsEvent( val event: String, var params: Map = mapOf(), val error: Throwable? = null, -) \ No newline at end of file +) { + + val id: String = "[$category] $event" +} \ No newline at end of file diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index 91b8eb6880..830d2621c2 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -114,6 +114,11 @@ sealed class AnalyticsParam { object SeedImport : WalletCreationType("Seed import") } + sealed class WalletType(val value: String) { + object MultiCurrency : WalletType(value = "Multicurrency") + class SingleCurrency(currencyName: String) : WalletType(currencyName) + } + companion object Key { const val BLOCKCHAIN = "blockchain" const val TOKEN = "Token" diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimeAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimeAnalyticsEvent.kt new file mode 100644 index 0000000000..82d9991c71 --- /dev/null +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/OneTimeAnalyticsEvent.kt @@ -0,0 +1,6 @@ +package com.tangem.core.analytics.models + +interface OneTimeAnalyticsEvent { + + val oneTimeEventId: String +} \ No newline at end of file diff --git a/core/analytics/src/main/AndroidManifest.xml b/core/analytics/src/main/AndroidManifest.xml deleted file mode 100644 index 9270c6ee97..0000000000 --- a/core/analytics/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/api/EventFilterApi.kt b/core/analytics/src/main/java/com/tangem/core/analytics/api/EventFilterApi.kt index 4de7e4aee8..dc94652208 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/api/EventFilterApi.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/api/EventFilterApi.kt @@ -16,7 +16,7 @@ interface AnalyticsEventFilter { * An internal filter check that, on external or internal conditions, recognizes the possibility of * sending an event. */ - fun canBeSent(event: AnalyticsEvent): Boolean + suspend fun canBeSent(event: AnalyticsEvent): Boolean /** * Performs a check to see if the event can be dispatched by a specific handler 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 0a5f3179de..7bedfebff3 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 @@ -12,13 +12,11 @@ interface AnalyticsEventHandler { interface AnalyticsHandler : AnalyticsEventHandler { fun id(): String - fun send(event: String, params: Map = emptyMap()) + fun send(eventId: String, params: Map = emptyMap()) override fun send(event: AnalyticsEvent) { - send(prepareEventString(event), event.params) + send(event.id, event.params) } - - fun prepareEventString(event: AnalyticsEvent): String = "[${event.category}] ${event.event}" } interface ErrorEventHandler { 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 3b12478c96..13715236c5 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 @@ -2,6 +2,8 @@ package com.tangem.core.analytics.di import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.filter.OneTimeEventFilter +import com.tangem.domain.analytics.repository.AnalyticsRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -10,11 +12,16 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -class AnalyticsModule { +internal object AnalyticsModule { @Singleton @Provides fun provideAnalyticsHandler(): AnalyticsEventHandler { return Analytics // todo replace after refactoring calling Analytics in whole project } + + @Provides + fun provideOneTimeEventFilter(analyticsRepository: AnalyticsRepository): OneTimeEventFilter { + return OneTimeEventFilter(analyticsRepository) + } } \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/filter/OneTimeEventFilter.kt b/core/analytics/src/main/java/com/tangem/core/analytics/filter/OneTimeEventFilter.kt new file mode 100644 index 0000000000..f7e9903e26 --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/filter/OneTimeEventFilter.kt @@ -0,0 +1,30 @@ +package com.tangem.core.analytics.filter + +import com.tangem.core.analytics.api.AnalyticsEventFilter +import com.tangem.core.analytics.api.AnalyticsHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.OneTimeAnalyticsEvent +import com.tangem.domain.analytics.repository.AnalyticsRepository + +class OneTimeEventFilter( + private val analyticsRepository: AnalyticsRepository, +) : AnalyticsEventFilter { + + override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is OneTimeAnalyticsEvent + + override suspend fun canBeSent(event: AnalyticsEvent): Boolean { + if (event !is OneTimeAnalyticsEvent) return true + + val isSent = analyticsRepository.checkIsEventSent(event.oneTimeEventId) + + if (!isSent) { + analyticsRepository.setIsEventSent(event.oneTimeEventId) + } + + return !isSent + } + + override fun canBeConsumedByHandler(handler: AnalyticsHandler, event: AnalyticsEvent): Boolean { + return canBeAppliedTo(event) + } +} \ No newline at end of file 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 5e88155058..e342289f0c 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 @@ -12,7 +12,11 @@ sealed class ApiResponseError : Exception() { * @property code The HTTP status code. * @property message A human-readable message describing the error. */ - data class HttpException(val code: Code, override val message: String?) : ApiResponseError() { + data class HttpException( + val code: Code, + override val message: String?, + val errorBody: String?, + ) : ApiResponseError() { // region Error Codes enum class Code(val code: Int) { 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 0ddb0ffa36..718392b0d9 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 @@ -16,7 +16,7 @@ internal fun Response.toSafeApiResponse(): ApiResponse { val e = if (code == null) { ApiResponseError.UnknownException(IllegalArgumentException("Unknown error status code: ${code()}")) } else { - ApiResponseError.HttpException(code, message()) + ApiResponseError.HttpException(code, message(), errorBody()?.string()) } apiError(e) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/ExpressApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt similarity index 70% rename from core/datasource/src/main/java/com/tangem/datasource/api/express/ExpressApi.kt rename to core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt index 0ed82c76d9..b78d485fa6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/ExpressApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/TangemExpressApi.kt @@ -8,13 +8,12 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Query -import java.math.BigDecimal /** * Interface of Tangem Express API (new swap mechanism) */ @Suppress("LongParameterList") -interface ExpressApi { +interface TangemExpressApi { @POST("assets") suspend fun getAssets(@Body body: AssetsRequestBody): ApiResponse> @@ -31,9 +30,11 @@ interface ExpressApi { @Query("fromNetwork") fromNetwork: String, @Query("toContractAddress") toContractAddress: String, @Query("toNetwork") toNetwork: String, - @Query("fromAmount") fromAmount: BigDecimal, - @Query("providerId") providerId: Int, - @Query("rateType") rateType: RateType, + @Query("fromAmount") fromAmount: String, + @Query("fromDecimals") fromDecimals: Int, + @Query("toDecimals") toDecimals: Int, + @Query("providerId") providerId: String, + @Query("rateType") rateType: String, ): ApiResponse @GET("exchange-data") @@ -42,12 +43,14 @@ interface ExpressApi { @Query("fromNetwork") fromNetwork: String, @Query("toContractAddress") toContractAddress: String, @Query("toNetwork") toNetwork: String, - @Query("fromAmount") fromAmount: BigDecimal, - @Query("providerId") providerId: Int, - @Query("rateType") rateType: RateType, + @Query("fromAmount") fromAmount: String, + @Query("fromDecimals") fromDecimals: Int, + @Query("toDecimals") toDecimals: Int, + @Query("providerId") providerId: String, + @Query("rateType") rateType: String, @Query("toAddress") toAddress: String, ): ApiResponse - @GET("exchange-result") - suspend fun getExchangeResults(@Query("txId") txId: String): ApiResponse + @GET("exchange-status") + suspend fun getExchangeStatus(@Query("txId") txId: String): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt new file mode 100644 index 0000000000..984184791c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/TangemExpressValues.kt @@ -0,0 +1,5 @@ +package com.tangem.datasource.api.express.models + +object TangemExpressValues { + const val EMPTY_CONTRACT_ADDRESS_VALUE = "0" +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/request/AssetsRequestBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/request/AssetsRequestBody.kt index f1d31bd24e..2d496a94ec 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/request/AssetsRequestBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/request/AssetsRequestBody.kt @@ -3,5 +3,5 @@ package com.tangem.datasource.api.express.models.request import com.squareup.moshi.Json data class AssetsRequestBody( - @Json(name = "filter") val filter: List?, + @Json(name = "tokensList") val tokensList: List?, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/Asset.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/Asset.kt index dc2fd581fe..87f83ea98f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/Asset.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/Asset.kt @@ -9,21 +9,6 @@ data class Asset( @Json(name = "network") val network: String, - @Json(name = "token") - val token: String, - - @Json(name = "name") - val name: String, - - @Json(name = "symbol") - val symbol: String, - - @Json(name = "decimals") - val decimals: Int, - - @Json(name = "isActive") - val isActive: Boolean, - @Json(name = "exchangeAvailable") val exchangeAvailable: Boolean, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt index 3083bdfd0b..72deee1a57 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeDataResponse.kt @@ -4,8 +4,17 @@ import com.squareup.moshi.Json import java.math.BigDecimal data class ExchangeDataResponse( + @Json(name = "fromAmount") + val fromAmount: String, + + @Json(name = "fromDecimals") + val fromDecimals: Int, + @Json(name = "toAmount") - val toAmount: BigDecimal, + val toAmount: String, + + @Json(name = "toDecimals") + val toDecimals: Int, @Json(name = "txType") val txType: TxType, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt index 934d1a8497..4e2bc6d69c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeProvider.kt @@ -4,19 +4,19 @@ import com.squareup.moshi.Json data class ExchangeProvider( @Json(name = "id") - val id: Int, + val id: String, @Json(name = "name") val name: String, - @Json(name = "id") + @Json(name = "type") val type: ExchangeProviderType, @Json(name = "imageLarge") - val imageLargeUrl: Int, + val imageLargeUrl: String, @Json(name = "imageSmall") - val imageSmallUrl: Int, + val imageSmallUrl: String, ) enum class ExchangeProviderType { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt index 214b4ac2d5..e643811822 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeQuoteResponse.kt @@ -4,9 +4,23 @@ import com.squareup.moshi.Json import java.math.BigDecimal data class ExchangeQuoteResponse( + + @Json(name = "fromAmount") + val fromAmount: String, + + @Json(name = "fromDecimals") + val fromDecimals: Int, + @Json(name = "toAmount") - val toAmount: BigDecimal, + val toAmount: String, + + @Json(name = "toDecimals") + val toDecimals: Int, @Json(name = "allowanceContract") val allowanceContract: String?, + + @Json(name = "minAmount") + val minAmount: BigDecimal, + ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeResultsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeResultsResponse.kt deleted file mode 100644 index c9b10f881e..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeResultsResponse.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tangem.datasource.api.express.models.response - -import com.squareup.moshi.Json - -data class ExchangeResultsResponse( - @Json(name = "status") - val status: ExchangeResultsStatus, - - @Json(name = "externalStatus") - val externalStatus: String, - - @Json(name = "externalTxUrl") - val externalTxUrl: String, - - @Json(name = "error") - val error: ExchangeResultsError?, -) - -enum class ExchangeResultsStatus { - @Json(name = "processing") - PROCESSING, - - @Json(name = "done") - DONE, - - @Json(name = "failed") - FAILED, - - @Json(name = "refunded") - REFUNDED, - - @Json(name = "verificationRequired") - VERIFICATION_REQUIRED, -} - -data class ExchangeResultsError( - @Json(name = "code") - val code: Int, - - @Json(name = "description") - val description: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeStatusResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeStatusResponse.kt new file mode 100644 index 0000000000..94cd9cbf1f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExchangeStatusResponse.kt @@ -0,0 +1,59 @@ +package com.tangem.datasource.api.express.models.response + +import com.squareup.moshi.Json + +data class ExchangeStatusResponse( + + @Json(name = "providerId") + val providerId: String, + + @Json(name = "externalTxId") + val externalTxId: String, + + @Json(name = "externalTxStatus") + val externalStatus: ExchangeStatus, + + @Json(name = "externalTxUrl") + val externalTxUrl: String, + + @Json(name = "error") + val error: ExchangeStatusError?, +) + +enum class ExchangeStatus { + + @Json(name = "new") + NEW, + + @Json(name = "waiting") + WAITING, + + @Json(name = "confirming") + CONFIRMING, + + @Json(name = "exchanging") + EXCHANGING, + + @Json(name = "sending") + SENDING, + + @Json(name = "finished") + FINISHED, + + @Json(name = "failed") + FAILED, + + @Json(name = "refunded") + REFUNDED, + + @Json(name = "verifying") + VERIFYING, +} + +data class ExchangeStatusError( + @Json(name = "code") + val code: Int, + + @Json(name = "description") + val description: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt new file mode 100644 index 0000000000..ea8a552360 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/ExpressErrorResponse.kt @@ -0,0 +1,37 @@ +package com.tangem.datasource.api.express.models.response + +import com.squareup.moshi.Json +import java.math.BigDecimal + +data class ExpressErrorResponse( + @Json(name = "error") + val error: ExpressError, +) + +data class ExpressError( + @Json(name = "code") + val code: Int, + + @Json(name = "description") + val description: String?, + + @Json(name = "value") + val value: ExpressErrorValue?, +) + +data class ExpressErrorValue( + @Json(name = "minAmount") + val minAmount: String?, + + @Json(name = "decimals") + val decimals: Int?, + + @Json(name = "currentAllowance") + val currentAllowance: BigDecimal?, + + @Json(name = "receivedFromDecimals") + val receivedFromDecimals: Int?, + + @Json(name = "expressFromDecimals") + val expressFromDecimals: Int?, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPair.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPair.kt index ba04207f85..f96aef7dfc 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPair.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPair.kt @@ -17,10 +17,10 @@ data class SwapPair( data class SwapPairProvider( @Json(name = "providerId") - val providerId: Int, + val providerId: String, - @Json(name = "rateType") - val rateType: RateType, + @Json(name = "rateTypes") + val rateTypes: List, ) enum class RateType { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPairsWithProviders.kt b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPairsWithProviders.kt new file mode 100644 index 0000000000..41568fb92f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/express/models/response/SwapPairsWithProviders.kt @@ -0,0 +1,6 @@ +package com.tangem.datasource.api.express.models.response + +class SwapPairsWithProviders( + val swapPair: List, + val providers: List, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MarketCoinsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AssetsStoreModule.kt similarity index 51% rename from core/datasource/src/main/java/com/tangem/datasource/di/MarketCoinsStoreModule.kt rename to core/datasource/src/main/java/com/tangem/datasource/di/AssetsStoreModule.kt index bd2098a670..43b8e93f5c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MarketCoinsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AssetsStoreModule.kt @@ -1,8 +1,8 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeDataStore -import com.tangem.datasource.local.token.DefaultUserMarketCoinsStore -import com.tangem.datasource.local.token.UserMarketCoinsStore +import com.tangem.datasource.local.token.DefaultAssetsStore +import com.tangem.datasource.local.token.AssetsStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -11,11 +11,11 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object MarketCoinsStoreModule { +internal object AssetsStoreModule { @Provides @Singleton - fun provideUserMarketCoinsStore(): UserMarketCoinsStore { - return DefaultUserMarketCoinsStore(dataStore = RuntimeDataStore()) + fun provideAssetsStore(): AssetsStore { + return DefaultAssetsStore(dataStore = RuntimeDataStore()) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index d8b4a287eb..754936c7a5 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,8 +2,9 @@ package com.tangem.datasource.di import android.content.Context import com.squareup.moshi.Moshi +import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.response.ApiResponseCallAdapterFactory -import com.tangem.datasource.api.express.ExpressApi +import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.promotion.PromotionApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.utils.RequestHeader.* @@ -32,11 +33,16 @@ class NetworkModule { @NetworkMoshi moshi: Moshi, @ApplicationContext context: Context, expressAuthProvider: ExpressAuthProvider, - ): ExpressApi { + ): TangemExpressApi { + val url = if (BuildConfig.ENVIRONMENT == "dev") { + DEV_EXPRESS_BASE_URL + } else { + PROD_EXPRESS_BASE_URL + } return Retrofit.Builder() .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(ApiResponseCallAdapterFactory.create()) - .baseUrl(DEV_EXPRESS_BASE_URL) + .baseUrl(url) .client( OkHttpClient.Builder() .addHeaders(Express(expressAuthProvider)) @@ -44,7 +50,7 @@ class NetworkModule { .build(), ) .build() - .create(ExpressApi::class.java) + .create(TangemExpressApi::class.java) } @Provides diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/SwapTransactionStatusStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/SwapTransactionStatusStoreModule.kt new file mode 100644 index 0000000000..25f71c60a0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/di/SwapTransactionStatusStoreModule.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.di + +import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.swaptx.DefaultSwapTransactionStatusStore +import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object SwapTransactionStatusStoreModule { + + @Provides + @Singleton + fun provideSwapTransactionStatusStore(): SwapTransactionStatusStore { + return DefaultSwapTransactionStatusStore( + dataStore = RuntimeDataStore(), + ) + } +} \ 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 ae7bb4ee8c..1619c322de 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 @@ -44,12 +44,20 @@ class AppPreferencesStore( return this[key]?.let(adapter::fromJson) } - /** Get nullable list of data [T] by string [key] */ + /** Get list of data [T] by string [key] */ inline fun MutablePreferences.getObjectList(key: Preferences.Key): List? { val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) return this[key]?.let(adapter::fromJson) } + /** Get map with [String] key and value [V] by string [key] from [MutablePreferences] */ + inline fun MutablePreferences.getObjectMap(key: Preferences.Key): Map? { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) + + return this[key]?.let(adapter::fromJson) + } + /** * Set data [T] by string [key] to [MutablePreferences] * @@ -67,4 +75,12 @@ class AppPreferencesStore( val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) this[key] = adapter.toJson(value) } + + /** Set map with [String] key and value [V] by string [key] to [MutablePreferences] */ + inline fun MutablePreferences.setObjectMap(key: Preferences.Key, value: Map) { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) + + this[key] = adapter.toJson(value) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index d81065c147..d10a9dab4f 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -32,6 +32,16 @@ object PreferencesKeys { val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") } val BALANCE_HIDING_SETTINGS_KEY by lazy { stringPreferencesKey(name = "balanceHidingSettings") } + + val SWAP_TRANSACTIONS_KEY by lazy { stringPreferencesKey(name = "swapTransactions") } + + val WALLETS_SCROLL_PREVIEW_KEY by lazy { booleanPreferencesKey(name = "walletsScrollPreview") } + + val SENT_ONE_TIME_EVENTS_KEY by lazy { stringPreferencesKey(name = "sentOneTimeEvents") } + + val WALLETS_BALANCES_STATES_KEY by lazy { stringPreferencesKey(name = "walletsBalancesStates") } + + val LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY by lazy { stringPreferencesKey(name = "lastSwappedCryptoCurrency") } } /** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore */ 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 5622eb2351..cd1facee55 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 @@ -103,9 +103,31 @@ inline fun AppPreferencesStore.getObjectList(key: Preferences.Key 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() +} + +/** Store map with [String] key and value [V] by string [key] */ +suspend inline fun AppPreferencesStore.storeObjectMap( + key: Preferences.Key, + value: Map, +) { + val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) + val adapter = moshi.adapter>(type) + + edit { it[key] = adapter.toJson(value) } +} + +/** Get map with [String] key and value [V] by string [key], or empty if data is not found */ +suspend inline fun AppPreferencesStore.getObjectMap(key: Preferences.Key): Map { + 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) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/DefaultSwapTransactionStatusStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/DefaultSwapTransactionStatusStore.kt new file mode 100644 index 0000000000..50b4f08c9d --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/DefaultSwapTransactionStatusStore.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.local.swaptx + +import com.tangem.datasource.local.datastore.core.StringKeyDataStore + +internal class DefaultSwapTransactionStatusStore( + private val dataStore: StringKeyDataStore, +) : SwapTransactionStatusStore, StringKeyDataStore by dataStore { + + override suspend fun getTransactionStatus(txId: String) = getSyncOrNull(txId) + + override suspend fun setTransactionStatus(txId: String, status: ExchangeAnalyticsStatus) = store(txId, status) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/SwapTransactionStatusStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/SwapTransactionStatusStore.kt new file mode 100644 index 0000000000..59bd30030e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/swaptx/SwapTransactionStatusStore.kt @@ -0,0 +1,18 @@ +package com.tangem.datasource.local.swaptx + +/** + * Runtime cache for storing swap transactions statuses sent to analytics + */ +interface SwapTransactionStatusStore { + suspend fun getTransactionStatus(txId: String): ExchangeAnalyticsStatus? + + suspend fun setTransactionStatus(txId: String, status: ExchangeAnalyticsStatus) +} + +enum class ExchangeAnalyticsStatus(val value: String) { + InProgress("In Progress"), + Done("Done"), + Fail("Fail"), + KYC("KYC"), + Refunded("Refunded"), +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/AssetsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/AssetsStore.kt new file mode 100644 index 0000000000..11d6101843 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/AssetsStore.kt @@ -0,0 +1,11 @@ +package com.tangem.datasource.local.token + +import com.tangem.datasource.api.express.models.response.Asset +import com.tangem.domain.wallets.models.UserWalletId + +interface AssetsStore { + + suspend fun getSyncOrNull(userWalletId: UserWalletId): List? + + suspend fun store(userWalletId: UserWalletId, item: List) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserMarketCoinsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultAssetsStore.kt similarity index 64% rename from core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserMarketCoinsStore.kt rename to core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultAssetsStore.kt index c08a627df9..9177ec70b7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultUserMarketCoinsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/token/DefaultAssetsStore.kt @@ -1,18 +1,18 @@ package com.tangem.datasource.local.token -import com.tangem.datasource.api.tangemTech.models.CoinsResponse +import com.tangem.datasource.api.express.models.response.Asset import com.tangem.datasource.local.datastore.core.StringKeyDataStore import com.tangem.domain.wallets.models.UserWalletId -internal class DefaultUserMarketCoinsStore( - private val dataStore: StringKeyDataStore, -) : UserMarketCoinsStore { +internal class DefaultAssetsStore( + private val dataStore: StringKeyDataStore>, +) : AssetsStore { - override suspend fun getSyncOrNull(userWalletId: UserWalletId): CoinsResponse? { + override suspend fun getSyncOrNull(userWalletId: UserWalletId): List? { return dataStore.getSyncOrNull(userWalletId.stringValue) } - override suspend fun store(userWalletId: UserWalletId, item: CoinsResponse) { + override suspend fun store(userWalletId: UserWalletId, item: List) { dataStore.store(userWalletId.stringValue, item) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserMarketCoinsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/token/UserMarketCoinsStore.kt deleted file mode 100644 index 10895699ac..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/local/token/UserMarketCoinsStore.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.datasource.local.token - -import com.tangem.datasource.api.tangemTech.models.CoinsResponse -import com.tangem.domain.wallets.models.UserWalletId - -interface UserMarketCoinsStore { - - suspend fun getSyncOrNull(userWalletId: UserWalletId): CoinsResponse? - - suspend fun store(userWalletId: UserWalletId, item: CoinsResponse) -} \ No newline at end of file diff --git a/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json b/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json index 9da64e96a0..2b5c465b78 100644 --- a/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json @@ -34,5 +34,9 @@ { "name": "REDESIGNED_SEND_SCREEN_ENABLED", "version": "undefined" + }, + { + "name": "WALLETS_SCROLLING_PREVIEW_ENABLED", + "version": "undefined" } ] diff --git a/core/res/src/main/res/values-de/strings-blockchain.xml b/core/res/src/main/res/values-de/strings-blockchain.xml index b6c425deeb..d438a29cd8 100644 --- a/core/res/src/main/res/values-de/strings-blockchain.xml +++ b/core/res/src/main/res/values-de/strings-blockchain.xml @@ -1,7 +1,6 @@ Erhalt der Gebühr fehlgeschlagen - Laden Sie %1$s+ %2$s auf um ein Konto zu erstellen Minimaler Betrag ist %s Restbestand zu klein Falsche Gebühr diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 3bc3defd6d..ddbf073872 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -13,6 +13,7 @@ Änderungen speichern Absenden Erfolg + Gebühr Zugangscode Sie müssen den richtigen Zugangscode eingeben, bevor Sie die Karte scannen. Langes Tippen @@ -40,7 +41,6 @@ Tag Memo inkl. Gebühr - Gebühr Niedrig Normal Priorität diff --git a/core/res/src/main/res/values-fr/strings-blockchain.xml b/core/res/src/main/res/values-fr/strings-blockchain.xml index 42c3c19b03..0f4d6dccec 100644 --- a/core/res/src/main/res/values-fr/strings-blockchain.xml +++ b/core/res/src/main/res/values-fr/strings-blockchain.xml @@ -1,7 +1,6 @@ Échec de réception des commissions - Pour créer un compte, téléchargez %1$s+ %2$s Le montant minimal est de %s Le reste est trop petit Commission non valide diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 097c597e6e..9ac49cb6d8 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -13,6 +13,7 @@ Sauvegarder les modifications Envoyer Avec succès + Commissions Code d\'accès Vous devrez entrer le mot de passe correct avant de scanner la carte Tenez la carte fermement @@ -40,7 +41,6 @@ Tag Memo Inclure les commissions - Commissions Bas Normal Priorité diff --git a/core/res/src/main/res/values-it/strings-blockchain.xml b/core/res/src/main/res/values-it/strings-blockchain.xml index 2d50632130..338c03f0da 100644 --- a/core/res/src/main/res/values-it/strings-blockchain.xml +++ b/core/res/src/main/res/values-it/strings-blockchain.xml @@ -1,7 +1,6 @@ Impossibile ottenere la commissione - Scarica %1$s+ %2$s per creare un account L\'importo minimo è di %s L\'importo residuo è molto basso Commissione non valida diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index aa5a2b3d47..f673e3bf38 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -13,6 +13,7 @@ Mantieni le modifiche Invia Con successo + Commissione Codice di accesso Prima di scansionare la carta sarà necessario inserire il codice di accesso corretto Mantenimento della carta @@ -40,7 +41,6 @@ Tag Memo Includi commissione - Commissione Insufficiente Normale Prioritario diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 2e854b1d6f..6fe946df8b 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -66,6 +66,7 @@ Одобрение Внимание Баланс: %s + Баланс биометрическую аутентификацию биометрией Купить @@ -85,9 +86,11 @@ Включено Ошибка Обменять - Посмотреть историю транзакций Обозреватель + Посмотреть историю + Посмотреть историю транзакций Обозреватель + Комиссия Сетевые комиссии за транзакции используются для поддержки безопасности сети, поощрения валидаторов, выделения ресурсов и определения приоритета транзакции. Свое Быстро @@ -191,9 +194,19 @@ Мои токены У вас нет добавленных токенов. Добавьте токены для обмена Недоступен для обмена с %s + Статус Провайдеры проводят транзакции, обеспечивая плавный и эффективный обмен токенами Выберите провайдера + Чтобы узнать причину, посетите сайт провайдера + Чтобы вернуть ваши деньги, посетите сайт провайдера + Посетите сайт провайдера для проверки + Провайдер запрашивает прохождение верификации + Список токенов в вашем кошельке + Получение наилучших курсов... + Провайдер Лучший курс + Доступно с %s + Недоступно для этой пары Требуется разрешение Информация ниже не является обязательной. Вы можете стереть её, если хотите. Расскажите, каких функций вам не хватает, и мы постараемся вам помочь. @@ -267,7 +280,6 @@ Введенные коды доступа не совпадают Вы добавили одну резервную карту. После того, как процесс будет завершен, Вы больше не сможете добавить карт. Если у Вас есть еще одна карта, добавьте ее в резервную копию. Хотите продолжить? Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас. - Баланс Добавить резервную карту Сканировать карту #%d Создать резервную копию @@ -418,7 +430,6 @@ Tag Memo Включая комиссию - Комиссия Низкая Нормальная Приоритетная @@ -629,12 +640,18 @@ Нравится Понятно! Очень круто! + Обновить Вы находитесь в режиме демо Демо режим включен Отсканированная вами карта является картой разработчика. Не используйте ее для создания своего кошелька. Не для пользователя! Cеть %1$s использует концепцию экзистенциального депозита. Если баланс вашего счета будет ниже %2$s, то он будет деактивирован, а средства на счете уничтожены. Для работы с сетью необходим депозит + У вас в списке нет монет доступных для обмена с %s + Нет доступных токенов для обмена + Cервис временно недоступен + Пожалуйста, измените сумму для обмена + Сумма для обмена должна быть не менее %s Возможно, данная карта - образец или подделка Ошибка проверки подлинности На этой карте осталось всего %s подписей. Вам следует вывести все ваши средства. diff --git a/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml b/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml index 5aa30cef8f..607268ec3a 100644 --- a/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml +++ b/core/res/src/main/res/values-zh-rTW/strings-blockchain.xml @@ -4,7 +4,6 @@ 遺留資產 獲取費用失敗 由於 Kaspa 的限制,只有%1$d UTXO 可以放入單次交易中。這意味著您只能發送%2$s或更少數量。您需要減少數量。 - 加載 %1$s+ %2$s 以創建帳戶 目標帳戶未激活。發送 %s 或更多以激活帳戶 最小數量是 %s 更動太小 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index b80f9ce410..f595e68773 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -90,6 +90,7 @@ 提交 成功 交換 + 費用 條款和條件 交易 我了解 @@ -178,7 +179,7 @@ 輸入的訪問密碼與初始訪問密碼不匹配 您已添加一張備用卡。備份過程完成後,您將無法添加更多備份卡。如果您還有一張卡,請將其添加到備份中。您想繼續備份過程嗎? 備份過程已部分完成。你現在不能退出 - 餘額 + 餘額 添加備用卡 掃描卡片 #%d 立即備份 @@ -294,7 +295,6 @@ Tag Memo 包含費用 - 費用 正常 優先 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index f5fdebf47a..345f06a0f6 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -64,6 +64,7 @@ Approval Attention Balance: %s + Balance biometric authentication biometrics Buy @@ -84,8 +85,10 @@ Error Exchange Explore + Explore history Explore transaction history Explorer + Fee Network transaction fees are used to support network security, incentivize validators, allocate resources, and determine transaction priority. Custom Fast @@ -185,14 +188,46 @@ Terms of Service Oops, the current version of the application is not ready to work with this card, please check for updates. You have used a card from another wallet. Tap the card associated with this wallet - You Receive - You Send + You receive + You send My tokens - You don\'t have any added tokens yet. Add tokens via Market to swap - Unavailable for swap from %s - Providers facilitate transactions, ensuring smooth and efficient token exchanges - Choose Provider - Best Rate + You haven\'t added any tokens yet. Add tokens via Market to swap + Cannot be swapped for %s + Status + Providers facilitate transactions, ensuring smooth and efficient token swaps + Choose provider + Estimated amount + Exchange by %s + Visit provider’s website to refund your money + Operation failed by provider + Visit provider’s website for verification + KYC verification required by provider + Confirmed + Confirming + Confirming… + Exchanged + Exchanging + Exchanging… + Failed + Deposit received + Awaiting deposit + Awaiting deposit… + Refunded + Sending to you + Sending to you… + Sent + Provider-sourced data. Estimated amount subject to change. + Exchange status + Verified + Verification required + List of all tokens added to your wallet + Fetching best rates... + Floating rate + Go to provider + Provider + Best rate + Available from %s + Unavailable for this pair Permission Needed The following information is optional. You can erase it if you don\'t want to share it. Tell us what functions you are missing, and we will try to help you. @@ -267,7 +302,6 @@ Entered access code didn\'t match the initial access code You\'ve added one backup card. When backup process is finished you can\'t add more backup cards. If you have one more card, add it to backup. Do you like to continue the backup process? The backup process is partly complete. You can\'t exit it now. - Balance Add a backup card Scan the card #%d Backup now @@ -413,9 +447,7 @@ Invalid Memo. It won\'t be added to the transaction. Tag Memo - Insufficient funds for transfer Include fee - Fee Low Normal Priority @@ -490,7 +522,7 @@ Meet Tangem Exchange, buy NFT\'s, make loans and deposits in more than 100 different decentralized services Web 3.0 Compatible - Approvals are considered an industry standard across all decentralized exchanges and protect your wallet from being accessed by a smart contract without your permission. By design, smart contracts can\'t access your tokens unless you approve access from your end. By \"unlocking\" your tokens, you are give permission to the 1inch smart contract to spend your assets. The miners of the network are compensated with a gas fee (paid by you) to record this action on the blockchain. Once permission has been granted you will be able to swap your token. + All decentralized exchanges require approvals to prevent smart contracts from accessing your wallet without your permission. By design, smart contracts can\'t access your tokens unless you approve. By \"unlocking\" your tokens, you authorize the 1-inch smart contract to spend them. The network\'s miners receive a gas fee (paid by you) to record this action on the blockchain. You can swap your token after giving approval. Approve Error: %s There was an error. Please try again. @@ -498,23 +530,25 @@ High price impact! Swapping this amount of selected tokens will cause a significant price impact and reduce your outcome. Insufficient funds - Not enough funds for fee in your %1$s wallet to create a transaction. Top up your %2$s wallet first. + Insufficient funds in your %1$s wallet to cover fees. Top up your %2$s wallet first. Transaction in progress... Waiting Approve Current transaction - The token approval network fee will be charged to confirm that you are the one allowing your token to be used for the exchange. + The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap. Give Permission Specify the approve limit for the selected token Amount %s Spender - Your Wallet - To continue you need to allow 1inch smart contracts to use your %s + Your wallet + To continue, grant 1inch smart contracts permission to use your %s Unlimited + You swap + You receive View in Explorer In progress Swap - Swap of %s to + Swap %s for Quotes include an additional Tangem commission of %s. This helps us deliver a top-of-the-line product. Other tokens Choose token @@ -636,12 +670,20 @@ Like it Ok, Got it! Really cool! + Refresh You are currently in the Demo mode Demo mode active The card you scanned is a developer card. Do not use it to create your wallet. Not for users! %1$s network requires an Existential Deposit. If your account drops below %2$s, it will be deactivated, and any remaining funds will be destroyed. Network requires Existential Deposit + You do not have any %s exchangeable coins in your list + No available tokens to swap + To make a transaction you need to deposit some %1$s %2$s + Unable to cover %s fee + Service temporary unavailable + Please change the amount to swap + The amount to swap must be at least %s This card might be a production sample or counterfeit Authenticity check failed Only %s signatures are left on this card. You must withdraw all of your funds. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Cards.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Cards.kt index 847c27307f..91dd9a2506 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Cards.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Cards.kt @@ -13,6 +13,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -162,11 +163,12 @@ fun CardWithIcon( * >Figma component */ @Composable -fun IconWithTitleAndDescription( +internal fun IconWithTitleAndDescription( title: String, - description: String, + description: String?, icon: @Composable () -> Unit, additionalContent: @Composable () -> Unit = {}, + iconBackground: Color = TangemTheme.colors.background.secondary, ) { Row( modifier = Modifier @@ -182,7 +184,7 @@ fun IconWithTitleAndDescription( Box( modifier = Modifier .background( - color = TangemTheme.colors.background.secondary, + color = iconBackground, shape = CircleShape, ) .height(TangemTheme.dimens.size40) @@ -205,12 +207,14 @@ fun IconWithTitleAndDescription( color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.subtitle1, ) - SpacerH4() - Text( - text = description, - color = TangemTheme.colors.text.secondary, - style = TangemTheme.typography.body2, - ) + if (description != null) { + SpacerH4() + Text( + text = description, + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2, + ) + } } additionalContent() diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt index 9bdff5f987..e67fbf3de2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/MiddleEllipsisText.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.text.TextLayoutResult @@ -18,10 +17,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.TextUnit +import com.tangem.core.ui.components.atoms.text.BoundCounter /** * https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose */ +@Deprecated("Use EllipsisText with TextEllipsis.Middle ellipsis instead") @Suppress("LongMethod") @Composable fun MiddleEllipsisText( @@ -138,38 +139,4 @@ fun MiddleEllipsisText( private const val ELLIPSIS_CHARACTERS_COUNT = 3 private const val ELLIPSIS_CHARACTER = '.' -private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "") - -private class BoundCounter( - private val text: String, - private val textLayoutResult: TextLayoutResult, - private val charPosition: (Int) -> Int, -) { - var string = "" - private set - var width = 0f - private set - - private var _nextCharWidth: Float? = null - private var invalidCharsCount = 0 - - fun widthWithNextChar(): Float = width + nextCharWidth() - - private fun nextCharWidth(): Float = _nextCharWidth ?: run { - var boundingBox: Rect - // invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630 - invalidCharsCount-- - do { - boundingBox = textLayoutResult - .getBoundingBox(charPosition(string.count() + ++invalidCharsCount)) - } while (boundingBox.right == 0f) - _nextCharWidth = boundingBox.width - boundingBox.width - } - - fun addNextChar() { - string += text[charPosition(string.count())] - width += nextCharWidth() - _nextCharWidth = null - } -} \ No newline at end of file +private val ellipsisText = List(ELLIPSIS_CHARACTERS_COUNT) { ELLIPSIS_CHARACTER }.joinToString(separator = "") \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/SuccessScreen.kt b/core/ui/src/main/java/com/tangem/core/ui/components/SuccessScreen.kt deleted file mode 100644 index c5c3feabc1..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/components/SuccessScreen.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.tangem.core.ui.components - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.res.TangemTheme - -/** - * Screen for showing result - * - * @param resultMessage message to show - * @param title title to show - * @param resultColor color which will tint the round icon of the result - * @param icon icon to show in the middle of the round icon - * @param secondaryButtonIcon icon to show in the secondary button - * @param secondaryButtonText label of the secondary button - * @param onSecondaryButtonClick action on clicking secondary button - * @param onButtonClick action on clicking "Done" button - * - * @see Figma component - */ -@Composable -fun ResultScreenContent( - resultMessage: AnnotatedString, - onButtonClick: () -> Unit, - modifier: Modifier = Modifier, - @StringRes title: Int = R.string.common_success, - resultColor: Color = TangemTheme.colors.icon.accent, - @DrawableRes icon: Int = R.drawable.ic_check_24, - @DrawableRes secondaryButtonIcon: Int? = null, - @StringRes secondaryButtonText: Int? = null, - onSecondaryButtonClick: (() -> Unit)? = null, -) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .background(TangemTheme.colors.background.secondary) - .padding( - horizontal = TangemTheme.dimens.spacing16, - vertical = TangemTheme.dimens.spacing32, - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - SpacerHHalf() - SuccessImage(resultColor = resultColor, icon = icon) - SpacerH50() - Text( - text = stringResource(id = title), - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - SpacerH12() - Text( - text = resultMessage, - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.secondary, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - SpacerHHalf() - if (onSecondaryButtonClick != null && secondaryButtonText != null) { - SecondaryButtonForResultScreen( - secondaryButtonText = secondaryButtonText, - secondaryButtonIcon = secondaryButtonIcon, - onSecondaryButtonClick = onSecondaryButtonClick, - ) - SpacerH12() - } - PrimaryButton( - text = stringResource(id = R.string.common_close), - modifier = Modifier - .fillMaxWidth(), - onClick = { onButtonClick() }, - ) - } -} - -@Composable -fun SuccessImage(resultColor: Color, @DrawableRes icon: Int) { - Box( - modifier = Modifier - .background( - color = resultColor.copy(alpha = 0.2f), - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .padding(TangemTheme.dimens.spacing24) - .background( - color = resultColor, - shape = CircleShape, - ) - .height(TangemTheme.dimens.size93) - .width(TangemTheme.dimens.size93), - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = icon), - contentDescription = null, - tint = TangemTheme.colors.icon.primary2, - modifier = Modifier.size(TangemTheme.dimens.size40), - ) - } - } -} - -@Composable -private fun SecondaryButtonForResultScreen( - @StringRes secondaryButtonText: Int, - onSecondaryButtonClick: () -> Unit, - @DrawableRes secondaryButtonIcon: Int? = null, -) { - if (secondaryButtonIcon != null) { - SecondaryButtonIconStart( - text = stringResource(id = secondaryButtonText), - iconResId = secondaryButtonIcon, - onClick = onSecondaryButtonClick, - modifier = Modifier.fillMaxWidth(), - ) - } else { - SecondaryButton( - text = stringResource(id = secondaryButtonText), - onClick = onSecondaryButtonClick, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -// region preview - -@Composable -private fun SuccessScreenPreview() { - ResultScreenContent( - resultMessage = AnnotatedString("Swap of 1 000 DAI to 1 131,46 MATIC"), - secondaryButtonText = R.string.swapping_success_view_explorer_button_title, - onSecondaryButtonClick = {}, - onButtonClick = {}, - ) -} - -@Preview(showBackground = true) -@Composable -private fun Preview_SuccessScreenContent_InLightTheme() { - TangemTheme(isDark = false) { - SuccessScreenPreview() - } -} - -@Preview(showBackground = true) -@Composable -private fun Preview_SuccessScreenContent_InDarkTheme() { - TangemTheme(isDark = true) { - SuccessScreenPreview() - } -} - -// endregion preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt b/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt index c4d41ef91b..8e0081a633 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/TangemSwitch.kt @@ -23,7 +23,7 @@ import com.tangem.core.ui.res.TangemTheme @Composable fun TangemSwitch( onCheckedChange: (Boolean) -> Unit, - checkedColor: Color = TangemTheme.colors.icon.accent, + checkedColor: Color = TangemTheme.colors.control.checked, uncheckedColor: Color = TangemTheme.colors.icon.informative, size: Dp = 48.dp, checked: Boolean = false, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Warnings.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Warnings.kt index b6b858d2cd..d0d3315f20 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Warnings.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Warnings.kt @@ -9,6 +9,7 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R @@ -36,6 +37,28 @@ fun WarningCard(title: String, description: String, icon: @Composable (() -> Uni ) } +/** + * A card with a warning icon to the left and title without description shown to the right of it. + * + * @param title title of the warning in bold + * + * @see Figma component + */ +@Composable +fun WarningCardTitleOnly(title: String, icon: @Composable (() -> Unit)? = null) { + WarningCardMaterial3Style( + content = { + WarningBody( + title = title, + description = null, + icon = icon, + iconBackground = TangemTheme.colors.button.disabled, + ) + }, + ) +} + /** * [WarningCard], but clickable (with an 'greater then' icon to the left) * @@ -105,7 +128,8 @@ fun RefreshableWaringCard( @Composable private fun WarningBody( title: String, - description: String, + description: String?, + iconBackground: Color = TangemTheme.colors.background.secondary, icon: @Composable (() -> Unit)? = null, additionalContent: @Composable () -> Unit = {}, ) { @@ -119,6 +143,7 @@ private fun WarningBody( contentDescription = null, ) }, + iconBackground = iconBackground, ) } @@ -136,6 +161,20 @@ private fun WarningCardSurface(onClick: (() -> Unit)? = null, content: @Composab } } +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun WarningCardMaterial3Style(onClick: (() -> Unit)? = null, content: @Composable () -> Unit) { + Card( + shape = RoundedCornerShape(TangemTheme.dimens.radius16), + backgroundColor = TangemTheme.colors.button.disabled, + elevation = TangemTheme.dimens.elevation0, + onClick = onClick ?: {}, + enabled = onClick != null, + ) { + content() + } +} + // endregion elements // region Preview diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButton.kt index da053a53f7..afb57565f8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButton.kt @@ -3,14 +3,13 @@ package com.tangem.core.ui.components.appbar import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -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.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.Text +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -49,7 +48,11 @@ fun AppBarWithBackButton( contentDescription = null, modifier = Modifier .size(size = TangemTheme.dimens.size24) - .clickable { onBackClick() }, + .clickable( + indication = rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + onClick = onBackClick, + ), tint = TangemTheme.colors.icon.primary1, ) if (!text.isNullOrBlank()) { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt index 73509da21f..6151499106 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/appbar/AppBarWithBackButtonAndIcon.kt @@ -4,10 +4,13 @@ import androidx.annotation.DrawableRes import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.Text +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -39,7 +42,10 @@ fun AppBarWithBackButtonAndIcon( contentDescription = null, modifier = Modifier .size(size = TangemTheme.dimens.size24) - .clickable { onBackClick() }, + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + ) { onBackClick() }, tint = TangemTheme.colors.icon.primary1, ) AnimatedContent( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/BoundCounter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/BoundCounter.kt new file mode 100644 index 0000000000..6a97038b3d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/BoundCounter.kt @@ -0,0 +1,38 @@ +package com.tangem.core.ui.components.atoms.text + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.text.TextLayoutResult + +internal class BoundCounter( + private val text: String, + private val textLayoutResult: TextLayoutResult, + private val charPosition: (Int) -> Int, +) { + var string = "" + private set + var width = 0f + private set + + private var _nextCharWidth: Float? = null + private var invalidCharsCount = 0 + + fun widthWithNextChar(): Float = width + nextCharWidth() + + private fun nextCharWidth(): Float = _nextCharWidth ?: run { + var boundingBox: Rect + // invalidCharsCount fixes this bug: https://issuetracker.google.com/issues/197146630 + invalidCharsCount-- + do { + boundingBox = textLayoutResult + .getBoundingBox(charPosition(string.count() + ++invalidCharsCount)) + } while (boundingBox.right == 0f) + _nextCharWidth = boundingBox.width + boundingBox.width + } + + fun addNextChar() { + string += text[charPosition(string.count())] + width += nextCharWidth() + _nextCharWidth = null + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/EllipsisText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/EllipsisText.kt new file mode 100644 index 0000000000..d95b813ee1 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/atoms/text/EllipsisText.kt @@ -0,0 +1,242 @@ +package com.tangem.core.ui.components.atoms.text + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +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.Constraints +import androidx.compose.ui.unit.TextUnit +import com.tangem.core.ui.res.TangemTheme + +sealed class TextEllipsis { + + object Middle : TextEllipsis() + + object End : TextEllipsis() + + data class OffsetEnd( + val offsetEnd: Int = 0, + val hasSeparator: Boolean = true, + ) : TextEllipsis() +} + +/** + * https://stackoverflow.com/questions/69083061/how-to-make-middle-ellipsis-in-text-with-jetpack-compose + * + * Customized Text with ellipsis. Ellipsis can be placed in: Middle, End or OffsetEnd (OffsetEnd with separator). + * + * * OffsetEnd can be useful to display big amounts with currency symbol. OffsetEnd 0 is equal to End. + */ +@Suppress("LongMethod") +@Composable +fun EllipsisText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + softWrap: Boolean = true, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, + ellipsis: TextEllipsis = TextEllipsis.End, +) { + val ellipsisText = remember(text) { + if (ellipsis is TextEllipsis.OffsetEnd && ellipsis.hasSeparator) { + ELLIPSIS_TEXT_WITH_SEPARATOR + } else { + ELLIPSIS_TEXT + } + } + + // some letters, like "r", will have less width when placed right before "." + // adding a space to prevent such case + val layoutText = remember(text) { "$text $ellipsisText" } + val textLayoutResultState = remember(layoutText) { + mutableStateOf(null) + } + SubcomposeLayout(modifier) { constraints -> + // result is ignored - we only need to fill our textLayoutResult + subcompose("measure") { + Text( + text = layoutText, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + softWrap = softWrap, + maxLines = 1, + onTextLayout = { textLayoutResultState.value = it }, + style = style, + ) + }.first().measure(Constraints()) + // to allow smart cast + val textLayoutResult = textLayoutResultState.value + ?: // shouldn't happen - onTextLayout is called before subcompose finishes + return@SubcomposeLayout layout(0, 0) {} + val placeable = subcompose("visible") { + val finalText = remember(text, textLayoutResult, constraints.maxWidth) { + if ( + text.isEmpty() || + textLayoutResult.getBoundingBox(text.indices.last).right <= constraints.maxWidth + ) { + // text not including ellipsis fits on the first line. + return@remember text + } + + var ellipsisWidth = 0f + layoutText.indices.toList() + .takeLast(ellipsisText.length) + .forEach widthLet@{ + ellipsisWidth += textLayoutResult.getBoundingBox(it).width + } + + val availableWidth = constraints.maxWidth - ellipsisWidth + val startCounter = BoundCounter(text, textLayoutResult) { it } + val endCounter = BoundCounter(text, textLayoutResult) { text.indices.last - it } + + when (ellipsis) { + TextEllipsis.Middle -> { + middleEllipsisText( + availableWidth, + startCounter, + endCounter, + ) + } + TextEllipsis.End -> { + offsetEndEllipsisText( + availableWidth = availableWidth, + startCounter = startCounter, + endCounter = endCounter, + ) + } + is TextEllipsis.OffsetEnd -> { + offsetEndEllipsisText( + availableWidth = availableWidth, + startCounter = startCounter, + endCounter = endCounter, + offsetEnd = ellipsis.offsetEnd, + withSeparator = ellipsis.hasSeparator, + ) + } + } + } + Text( + text = finalText, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + softWrap = softWrap, + onTextLayout = onTextLayout, + style = style, + ) + }[0].measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + } +} + +private const val ELLIPSIS_SEPARATOR = " " +private const val ELLIPSIS_TEXT = "..." +private const val ELLIPSIS_TEXT_WITH_SEPARATOR = ELLIPSIS_TEXT.plus(ELLIPSIS_SEPARATOR) + +private fun middleEllipsisText(availableWidth: Float, startCounter: BoundCounter, endCounter: BoundCounter): String { + while (availableWidth - startCounter.width - endCounter.width > 0) { + val possibleEndWidth = endCounter.widthWithNextChar() + if ( + startCounter.width >= possibleEndWidth && + availableWidth - startCounter.width - possibleEndWidth >= 0 + ) { + endCounter.addNextChar() + } else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) { + startCounter.addNextChar() + } else { + break + } + } + return startCounter.string.trimEnd() + ELLIPSIS_TEXT + endCounter.string.reversed().trimStart() +} + +private fun offsetEndEllipsisText( + availableWidth: Float, + startCounter: BoundCounter, + endCounter: BoundCounter, + offsetEnd: Int = 0, + withSeparator: Boolean = false, +): String { + while (availableWidth - startCounter.width - endCounter.width > 0) { + val possibleEndWidth = endCounter.widthWithNextChar() + if ( + offsetEnd > endCounter.string.length && + availableWidth - startCounter.width - possibleEndWidth >= 0 + ) { + endCounter.addNextChar() + } else if (availableWidth - startCounter.widthWithNextChar() - endCounter.width >= 0) { + startCounter.addNextChar() + } else { + break + } + } + val ellipsis = if (withSeparator) ELLIPSIS_TEXT_WITH_SEPARATOR else ELLIPSIS_TEXT + + return startCounter.string.trimEnd() + ellipsis + endCounter.string.reversed().trimStart() +} + +//region Preview +@Preview(widthDp = 200) +@Composable +private fun EllipsisTexPreview(@PreviewParameter(EllipsisTexPreviewParameterProvider::class) ellipsis: TextEllipsis) { + TangemTheme { + EllipsisText( + text = "11111111111111111111111111111111111111111111111111 END", + ellipsis = ellipsis, + modifier = Modifier + .background(TangemTheme.colors.background.primary) + .fillMaxWidth(), + ) + } +} + +private class EllipsisTexPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TextEllipsis.Middle, + TextEllipsis.End, + TextEllipsis.OffsetEnd("TEXT".length), + TextEllipsis.OffsetEnd("TEXT".length, false), + ) +} +//endregion \ 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/TangemBottomSheet.kt index 2d9d85b164..7627f60b0e 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/TangemBottomSheet.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color import com.tangem.core.ui.res.TangemTheme import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -20,6 +21,7 @@ import kotlinx.coroutines.launch @Composable inline fun TangemBottomSheet( config: TangemBottomSheetConfig, + contentColor: Color = TangemTheme.colors.background.primary, crossinline content: @Composable ColumnScope.(T) -> Unit, ) { var isVisible by remember { mutableStateOf(value = config.isShow) } @@ -29,9 +31,9 @@ inline fun TangemBottomSheet( ModalBottomSheet( onDismissRequest = config.onDismissRequest, sheetState = sheetState, - containerColor = TangemTheme.colors.background.primary, + containerColor = contentColor, shape = TangemTheme.shapes.bottomSheetLarge, - dragHandle = { TangemBottomSheetDraggableHeader() }, + dragHandle = { TangemBottomSheetDraggableHeader(contentColor) }, ) { content(config.content) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountVisualTransformation.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt similarity index 93% rename from core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountVisualTransformation.kt rename to core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt index 89e4fa09ce..b3f27e218e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/AmountVisualTransformation.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt @@ -1,4 +1,4 @@ -package com.tangem.core.ui.components.fields +package com.tangem.core.ui.components.fields.visualtransformations import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowApprox.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowApprox.kt new file mode 100644 index 0000000000..b1c49c1291 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowApprox.kt @@ -0,0 +1,162 @@ +package com.tangem.core.ui.components.inputrow + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +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.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.components.currency.tokenicon.TokenIcon +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +/** + * [Input Row Approx](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2207-810&mode=design&t=fM1ZU6zQF6g3CaTv-4) + * + * @param leftIcon left token state + * @param leftTitle left token title + * @param leftSubtitle left token subtitle + * @param rightIcon right token state + * @param rightTitle right token title + * @param rightSubtitle right token subtitle + * @param modifier composable modifier + * @param showDivider show divider + */ +@Suppress("LongParameterList") +@Composable +fun InputRowApprox( + leftIcon: TokenIconState, + leftTitle: TextReference, + leftSubtitle: TextReference, + rightIcon: TokenIconState, + rightTitle: TextReference, + rightSubtitle: TextReference, + modifier: Modifier = Modifier, + leftTitleEllipsisOffset: Int = 0, + rightTitleEllipsisOffset: Int = 0, + showDivider: Boolean = false, +) { + DividerContainer( + showDivider = showDivider, + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(TangemTheme.dimens.spacing12) + .fillMaxWidth(), + ) { + InputRowApproxItem( + iconState = leftIcon, + title = leftTitle, + subtitle = leftSubtitle, + titleEllipsisOffset = leftTitleEllipsisOffset, + modifier = Modifier.weight(1f), + ) + Icon( + painter = painterResource(id = R.drawable.ic_approx_24), + contentDescription = null, + tint = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing4, + vertical = TangemTheme.dimens.spacing10, + ), + ) + InputRowApproxItem( + iconState = rightIcon, + title = rightTitle, + subtitle = rightSubtitle, + titleEllipsisOffset = rightTitleEllipsisOffset, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun InputRowApproxItem( + iconState: TokenIconState, + title: TextReference, + subtitle: TextReference, + modifier: Modifier = Modifier, + titleEllipsisOffset: Int = 0, +) { + Row( + modifier = modifier, + ) { + TokenIcon( + state = iconState, + modifier = Modifier + .size(TangemTheme.dimens.size36), + ) + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing12, + ), + ) { + EllipsisText( + text = title.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ellipsis = TextEllipsis.OffsetEnd(titleEllipsisOffset), + ) + EllipsisText( + text = subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing2), + ) + } + } +} + +//region Preview +@Preview +@Composable +private fun InputRowApproxPreview_Light() { + TangemTheme { + InputRowApprox( + leftIcon = TokenIconState.Loading, + leftTitle = TextReference.Str("Left title USD"), + leftSubtitle = TextReference.Str("Left subtitle USD"), + leftTitleEllipsisOffset = 3, + rightIcon = TokenIconState.Loading, + rightTitle = TextReference.Str("Right title Right title Right title Right title Right title USD"), + rightSubtitle = TextReference.Str("Right subtitle Right subtitle Right subtitle USD"), + rightTitleEllipsisOffset = 3, + modifier = Modifier + .background(TangemTheme.colors.background.action), + ) + } +} + +@Preview +@Composable +private fun InputRowApproxPreview_Dark() { + TangemTheme(isDark = true) { + InputRowApprox( + leftIcon = TokenIconState.Loading, + leftTitle = TextReference.Str("Left title Left title Left title Left title Left title USD"), + leftSubtitle = TextReference.Str("Left subtitle Left subtitle Left subtitle USD"), + leftTitleEllipsisOffset = 3, + rightIcon = TokenIconState.Loading, + rightTitle = TextReference.Str("Right title USD"), + rightSubtitle = TextReference.Str("Right subtitle USD"), + rightTitleEllipsisOffset = 3, + modifier = Modifier + .background(TangemTheme.colors.background.action), + ) + } +} +//endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt new file mode 100644 index 0000000000..620e3346cf --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt @@ -0,0 +1,221 @@ +package com.tangem.core.ui.components.inputrow + +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.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.components.currency.tokenicon.LoadingIcon +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme + +/** + * [Input Row Best Rate](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-889&mode=dev) + * + * @param imageUrl image source url + * @param title title + * @param titleExtra title extra + * @param subtitle subtitle + * @param modifier composable modifier + * @param showTag show tag + * @param showDivider show divider + * @param onIconClick icon click + */ +@Composable +fun InputRowBestRate( + imageUrl: String, + title: TextReference, + titleExtra: TextReference, + subtitle: TextReference, + modifier: Modifier = Modifier, + showTag: Boolean = false, + showDivider: Boolean = false, + onIconClick: (() -> Unit)? = null, +) { + DividerContainer( + showDivider = showDivider, + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(TangemTheme.dimens.spacing12), + ) { + InnerIcon(imageUrl = imageUrl) + Column( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12), + ) { + InnerTitle( + title = title, + titleExtra = titleExtra, + showTag = showTag, + ) + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) + } + SpacerWMax() + onIconClick?.let { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing10) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onClick = onIconClick, + ), + ) + } + } + } +} + +@Composable +private fun InnerTitle(title: TextReference, titleExtra: TextReference, showTag: Boolean = false) { + Row { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = titleExtra.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4), + ) + if (showTag) { + Text( + text = stringResource(R.string.express_provider_best_rate), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing4) + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = RoundedCornerShape(TangemTheme.dimens.radius20), + ) + .padding(horizontal = TangemTheme.dimens.spacing6), + ) + } + } +} + +@Composable +private fun InnerIcon(imageUrl: String) { + SubcomposeAsyncImage( + modifier = Modifier.size(TangemTheme.dimens.size40), + model = ImageRequest.Builder(context = LocalContext.current) + .data(imageUrl) + .crossfade(enable = true) + .allowHardware(enable = false) + .build(), + loading = { LoadingIcon() }, + error = { + Box( + modifier = Modifier + .background( + color = TangemTheme.colors.background.tertiary, + shape = CircleShape, + ), + ) + }, + contentDescription = null, + ) +} + +//region preview +@Preview +@Composable +private fun InputRowBestRatePreview_Light( + @PreviewParameter(InputRowBestRatePreviewDataProvider::class) data: InputRowBestRatePreviewData, +) { + TangemTheme { + InputRowBestRate( + imageUrl = "", + title = data.title, + titleExtra = data.titleExtra, + subtitle = data.subtitle, + showTag = data.showTag, + onIconClick = data.iconClick, + modifier = Modifier + .background(TangemTheme.colors.background.action), + ) + } +} + +@Preview +@Composable +private fun InputRowBestRatePreview_Dark( + @PreviewParameter(InputRowBestRatePreviewDataProvider::class) data: InputRowBestRatePreviewData, +) { + TangemTheme(isDark = true) { + InputRowBestRate( + imageUrl = "", + title = data.title, + titleExtra = data.titleExtra, + subtitle = data.subtitle, + showTag = data.showTag, + onIconClick = data.iconClick, + modifier = Modifier + .background(TangemTheme.colors.background.action), + ) + } +} + +private data class InputRowBestRatePreviewData( + val title: TextReference, + val titleExtra: TextReference, + val showTag: Boolean, + val subtitle: TextReference, + val iconClick: (() -> Unit)?, +) + +private class InputRowBestRatePreviewDataProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + InputRowBestRatePreviewData( + title = TextReference.Str("1inch"), + titleExtra = TextReference.Str("DEX"), + subtitle = TextReference.Str("0,64554846 DAI ≈ 1 MATIC "), + showTag = true, + iconClick = {}, + ), + InputRowBestRatePreviewData( + title = TextReference.Str("ChangeNow"), + titleExtra = TextReference.Str("CEX"), + subtitle = TextReference.Str("0,64554846 DAI ≈ 1 MATIC "), + showTag = false, + iconClick = null, + ), + ) +} +//endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt index 2f30c95eba..c352cf41eb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/notifications/Notification.kt @@ -8,7 +8,9 @@ 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.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -42,8 +44,18 @@ import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsSta * >Figma component */ @Composable -fun Notification(config: NotificationConfig, modifier: Modifier = Modifier, iconTint: Color? = null) { - BaseContainer(buttonsState = config.buttonsState, onClick = config.onClick, modifier = modifier) { +fun Notification( + config: NotificationConfig, + modifier: Modifier = Modifier, + containerColor: Color? = null, + iconTint: Color? = null, +) { + BaseContainer( + buttonsState = config.buttonsState, + onClick = config.onClick, + modifier = modifier, + containerColor = containerColor, + ) { Column( modifier = Modifier.padding(all = TangemTheme.dimens.spacing12), verticalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing12), @@ -71,9 +83,10 @@ private fun BaseContainer( buttonsState: NotificationConfig.ButtonsState?, onClick: (() -> Unit)?, modifier: Modifier = Modifier, + containerColor: Color? = null, content: @Composable BoxScope.() -> Unit, ) { - val containerColor by rememberUpdatedState( + val tempContainerColor by rememberUpdatedState( newValue = if (buttonsState != null || onClick != null) { TangemTheme.colors.background.primary } else { @@ -88,7 +101,7 @@ private fun BaseContainer( .fillMaxWidth(), enabled = onClick != null, shape = TangemTheme.shapes.roundedCornersXMedium, - color = containerColor, + color = containerColor ?: tempContainerColor, ) { Box(content = content) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt new file mode 100644 index 0000000000..0c2a785acc --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/ActionRow.kt @@ -0,0 +1,85 @@ +package com.tangem.core.ui.components.rows + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.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.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH28 +import com.tangem.core.ui.res.TangemTheme + +/** + * Simple clickable action row, without input and icon + * + * https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-807&mode=design&t=Ygv5sohTTHYAQcBS-4 + */ +@Composable +fun SimpleActionRow(title: String, description: String, modifier: Modifier = Modifier, isClickable: Boolean = true) { + Box( + modifier = modifier + .background(color = TangemTheme.colors.background.action) + .height(TangemTheme.dimens.size44) + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .padding(end = TangemTheme.dimens.spacing48) + .align(Alignment.CenterStart), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + AnimatedContent(targetState = title, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) + } + AnimatedContent(targetState = description, label = "") { + Text( + text = it, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + } + + if (isClickable) { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + tint = TangemTheme.colors.icon.informative, + ) + } + } +} + +@Preview +@Composable +private fun SimpleActionRowPreview() { + Column { + TangemTheme(isDark = false) { + SimpleActionRow( + title = "Title", + description = "Description", + ) + } + + SpacerH28() + + TangemTheme(isDark = false) { + SimpleActionRow( + title = "Title", + description = "Description", + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt new file mode 100644 index 0000000000..667ec44c08 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/rows/SelectorRowItem.kt @@ -0,0 +1,160 @@ +package com.tangem.core.ui.components.rows + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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 + +@Composable +fun SelectorRowItem( + @StringRes titleRes: Int, + @DrawableRes iconRes: Int, + onSelect: () -> Unit, + modifier: Modifier = Modifier, + preEllipsize: TextReference? = null, + postEllipsize: TextReference? = null, + isSelected: Boolean = false, + showDivider: Boolean = true, +) { + val iconTint by animateColorAsState( + targetValue = if (isSelected) { + TangemTheme.colors.icon.accent + } else { + TangemTheme.colors.icon.informative + }, + label = "Selector icon tint change", + ) + + val textStyle = if (isSelected) { + TangemTheme.typography.subtitle2 + } else { + TangemTheme.typography.body2 + } + + Box( + modifier = modifier + .fillMaxWidth() + .clickable { onSelect() }, + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Icon( + painter = painterResource(iconRes), + tint = iconTint, + contentDescription = null, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing12, + ), + ) + Text( + text = stringResource(titleRes), + style = textStyle, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing8, + top = TangemTheme.dimens.spacing14, + bottom = TangemTheme.dimens.spacing14, + ), + ) + if (preEllipsize != null && postEllipsize != null) { + SelectorValueContent( + amount = preEllipsize, + symbol = postEllipsize, + textStyle = textStyle, + ) + } + } + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(TangemTheme.dimens.size1) + .padding(horizontal = TangemTheme.dimens.spacing12) + .background(TangemTheme.colors.stroke.primary) + .align(Alignment.BottomCenter), + ) + } + } +} + +@Composable +private fun RowScope.SelectorValueContent(amount: TextReference, symbol: TextReference, textStyle: TextStyle) { + Text( + text = amount.resolveReference(), + style = textStyle, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.End, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier + .weight(1f) + .padding( + start = TangemTheme.dimens.spacing4, + top = TangemTheme.dimens.spacing14, + bottom = TangemTheme.dimens.spacing14, + ), + ) + Text( + text = symbol.resolveReference(), + style = textStyle, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing1, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing14, + bottom = TangemTheme.dimens.spacing14, + ), + ) +} + +@Preview +@Composable +private fun SelectorRowItemPreview_Light() { + TangemTheme { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_slow, + iconRes = R.drawable.ic_tortoise_24, + preEllipsize = TextReference.Str("1000"), + postEllipsize = TextReference.Str("$"), + isSelected = true, + onSelect = { }, + ) + } +} + +@Preview +@Composable +private fun SelectorRowItemPreview_Dark() { + TangemTheme(isDark = true) { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_slow, + iconRes = R.drawable.ic_tortoise_24, + preEllipsize = TextReference.Str("1000"), + postEllipsize = TextReference.Str("$"), + isSelected = true, + onSelect = { }, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt new file mode 100644 index 0000000000..d576eafe66 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt @@ -0,0 +1,87 @@ +package com.tangem.core.ui.components.transactions + +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +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.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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.toDateFormat +import com.tangem.core.ui.utils.toTimeFormat + +/** + * Common transaction done screen title + * + * @param titleRes title resource + * @param date transaction timestamp in millis + */ +@Composable +fun TransactionDoneTitle(@StringRes titleRes: Int, date: Long, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.ic_empty_in_process_64), + contentDescription = null, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing8) + .size(TangemTheme.dimens.size64), + ) + Text( + text = stringResource(id = titleRes), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing32), + ) + Text( + text = stringResource(id = R.string.send_date_format, date.toDateFormat(), date.toTimeFormat()), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing4), + ) + } +} + +// region Previews +@Preview +@Composable +private fun TransactionDoneTitlePreview_Light() { + TangemTheme { + TransactionDoneTitle( + titleRes = R.string.sent_transaction_sent_title, + date = 0, + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing16), + ) + } +} + +@Preview +@Composable +private fun TransactionDoneTitlePreview_Dark() { + TangemTheme(isDark = true) { + TransactionDoneTitle( + titleRes = R.string.sent_transaction_sent_title, + date = 0, + modifier = Modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing16), + ) + } +} +// endregion \ No newline at end of file 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 362f4d95e9..4a014cd824 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 @@ -7,18 +7,34 @@ import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import com.tangem.core.ui.res.TangemTheme -fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modifier = composed { - val modifierWithHorizontalPadding = this.padding(horizontal = TangemTheme.dimens.spacing16) +fun Modifier.roundedShapeItemDecoration( + currentIndex: Int, + lastIndex: Int, + addDefaultPadding: Boolean = true, +): Modifier = composed { + val modifier = if (addDefaultPadding) this.padding(horizontal = TangemTheme.dimens.spacing16) else this val isSingleItem = currentIndex == 0 && lastIndex == 0 when { isSingleItem -> { - modifierWithHorizontalPadding - .padding(top = TangemTheme.dimens.spacing14) + modifier + .then( + if (addDefaultPadding) { + Modifier.padding(top = TangemTheme.dimens.spacing14) + } else { + Modifier + }, + ) .clip(shape = TangemTheme.shapes.roundedCornersXMedium) } currentIndex == 0 -> { - modifierWithHorizontalPadding - .padding(top = TangemTheme.dimens.spacing14) + modifier + .then( + if (addDefaultPadding) { + Modifier.padding(top = TangemTheme.dimens.spacing14) + } else { + Modifier + }, + ) .clip( shape = RoundedCornerShape( topStart = TangemTheme.dimens.radius16, @@ -27,7 +43,7 @@ fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modi ) } currentIndex == lastIndex -> { - modifierWithHorizontalPadding + modifier .clip( shape = RoundedCornerShape( bottomStart = TangemTheme.dimens.radius16, @@ -35,6 +51,6 @@ fun Modifier.roundedShapeItemDecoration(currentIndex: Int, lastIndex: Int): Modi ), ) } - else -> modifierWithHorizontalPadding + else -> modifier } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/event/EventEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/event/EventEffect.kt index e6f3ad0d84..516dba38e6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/event/EventEffect.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/event/EventEffect.kt @@ -3,6 +3,7 @@ package com.tangem.core.ui.event import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.NonRestartableComposable +import kotlinx.coroutines.launch /** * A Composable function that reacts to a given [StateEvent], executing the provided action only once when the event @@ -17,8 +18,8 @@ import androidx.compose.runtime.NonRestartableComposable fun EventEffect(event: StateEvent, onTrigger: suspend (data: A) -> Unit) { LaunchedEffect(event) { if (event is StateEvent.Triggered) { - onTrigger(event.data) - event.onConsume() + launch { onTrigger(event.data) } + .invokeOnCompletion { event.onConsume() } } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt index cf0b07bbfe..277d3f4a52 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt @@ -32,6 +32,16 @@ fun CryptoCurrency.Token.tryGetBackgroundForTokenIcon( ): Color { if (isGrayscale) return TangemColorPalette.Dark2 + return tryGetBackgroundForTokenIcon(contractAddress = contractAddress, fallbackColor = fallbackColor) +} + +/** + * Tries to extract a background color from the contract address of a token. + * + * @param fallbackColor The color to use as a fallback. + * @return The extracted background color or the fallback color if extraction fails or if it is a test network token. + */ +fun tryGetBackgroundForTokenIcon(contractAddress: String, fallbackColor: Color = TangemColorPalette.Black): Color { return try { val colorHex = "#" + contractAddress.substring(range = COLOR_HEX_START_INDEX..COLOR_HEX_END_INDEX) Color(colorHex.toColorInt()) diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt index d44b32ecdf..766815d328 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/TextReference.kt @@ -104,6 +104,16 @@ fun combinedReference(refs: WrappedList): TextReference { return TextReference.Combined(refs) } +/** + * Combines multiple [TextReference] instances into a single [TextReference]. + * + * @param refs Vararg of [TextReference] instances to be combined. + * @return A [TextReference] representing the combined text references. + */ +fun combinedReference(vararg refs: TextReference): TextReference { + return TextReference.Combined(WrappedList(listOf(*refs))) +} + /** Resolve [TextReference] as [String] */ @Composable @ReadOnlyComposable diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt index 2fc319885d..e68dfd96a0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt @@ -41,6 +41,7 @@ data class TangemDimens internal constructor( val size0: Dp = 0.dp, val size0_5: Dp = 0.5.dp, val size1: Dp = 1.dp, + val size1_5: Dp = 1.5.dp, val size2: Dp = 2.dp, val size4: Dp = 4.dp, val size5: Dp = 5.dp, 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 93dae822ba..d2c890aad3 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 @@ -157,7 +157,7 @@ private fun darkThemeColors(): TangemColors { ), button = TangemColors.Button( primary = TangemColorPalette.Light4, - secondary = TangemColorPalette.Dark5, + secondary = TangemColorPalette.Dark4, disabled = TangemColorPalette.Dark5, positiveDisabled = TangemColorPalette.DarkGreen, ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt index d8e209f0ad..c64c860e28 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt @@ -1,5 +1,6 @@ package com.tangem.core.ui.utils +import com.tangem.domain.tokens.model.CryptoCurrency import java.math.BigDecimal import java.math.RoundingMode import java.text.NumberFormat @@ -24,6 +25,10 @@ object BigDecimalFormatter { return formatter.format(cryptoAmount) + "\u2009$cryptoCurrency" } + fun formatCryptoAmount(cryptoAmount: BigDecimal?, cryptoCurrency: CryptoCurrency): String { + return formatCryptoAmount(cryptoAmount, cryptoCurrency.symbol, cryptoCurrency.decimals) + } + fun formatFiatAmount( fiatAmount: BigDecimal?, fiatCurrencyCode: String, diff --git a/core/ui/src/main/res/drawable/ic_alert_triangle_20.xml b/core/ui/src/main/res/drawable/ic_alert_triangle_20.xml new file mode 100644 index 0000000000..dfe1b75e71 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_alert_triangle_20.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_approx_24.xml b/core/ui/src/main/res/drawable/ic_approx_24.xml new file mode 100644 index 0000000000..30eb78ec9d --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_approx_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_empty_in_process_64.xml b/core/ui/src/main/res/drawable/ic_empty_in_process_64.xml new file mode 100644 index 0000000000..e75b15979e --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_empty_in_process_64.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_exclamation_24.xml b/core/ui/src/main/res/drawable/ic_exclamation_24.xml new file mode 100644 index 0000000000..d7d5bdca87 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_exclamation_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_forward_24.xml b/core/ui/src/main/res/drawable/ic_forward_24.xml new file mode 100644 index 0000000000..1a35b84bfb --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_forward_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_no_token_44.xml b/core/ui/src/main/res/drawable/ic_no_token_44.xml new file mode 100644 index 0000000000..49e3b75b2a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_no_token_44.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_web_24.xml b/core/ui/src/main/res/drawable/ic_web_24.xml new file mode 100644 index 0000000000..69e2fc27d9 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_web_24.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/core/utils/src/main/java/com/tangem/utils/CryptoCurrencyFormatExtensions.kt b/core/utils/src/main/java/com/tangem/utils/CryptoCurrencyFormatExtensions.kt index 1e266be497..a1995e1712 100644 --- a/core/utils/src/main/java/com/tangem/utils/CryptoCurrencyFormatExtensions.kt +++ b/core/utils/src/main/java/com/tangem/utils/CryptoCurrencyFormatExtensions.kt @@ -25,7 +25,7 @@ fun BigDecimal.toFormattedString( @Suppress("MagicNumber") fun BigDecimal.toFormattedCurrencyString( decimals: Int, - currency: String, + currency: String? = null, roundingMode: RoundingMode = RoundingMode.DOWN, limitNumberOfDecimals: Boolean = true, ): String { @@ -38,7 +38,8 @@ fun BigDecimal.toFormattedCurrencyString( decimals = decimalsForRounding, roundingMode = roundingMode, ) - return "$formattedAmount $currency" + val formattedCurrency = currency?.let { " $it " } ?: "" + return "$formattedAmount$formattedCurrency" } fun BigDecimal.toFiatString( diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt similarity index 96% rename from features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt rename to core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt index e1343a73cd..5db470b916 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/PeriodicTask.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.viewmodels +package com.tangem.utils.coroutines import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay diff --git a/data/analytics/.gitignore b/data/analytics/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/data/analytics/.gitignore @@ -0,0 +1 @@ +/build diff --git a/data/analytics/build.gradle.kts b/data/analytics/build.gradle.kts new file mode 100644 index 0000000000..293c5cf974 --- /dev/null +++ b/data/analytics/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.data.analytics" +} + +dependencies { + + /** Project - Domain */ + implementation(projects.domain.analytics) + implementation(projects.domain.wallets.models) + + /** Project - Analytics */ + implementation(projects.core.analytics.models) + + /** Project - Data */ + implementation(projects.core.datasource) + implementation(projects.data.common) + + /** AndroidX */ + implementation(deps.androidx.datastore) + + /** DI */ + implementation(deps.hilt.core) + kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.kotlin.coroutines) + implementation(deps.timber) +} \ No newline at end of file diff --git a/data/analytics/src/main/kotlin/com/tangem/data/analytics/DefaultAnalyticsRepository.kt b/data/analytics/src/main/kotlin/com/tangem/data/analytics/DefaultAnalyticsRepository.kt new file mode 100644 index 0000000000..4866aa1c89 --- /dev/null +++ b/data/analytics/src/main/kotlin/com/tangem/data/analytics/DefaultAnalyticsRepository.kt @@ -0,0 +1,50 @@ +package com.tangem.data.analytics + +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectListSync +import com.tangem.datasource.local.preferences.utils.getObjectMap +import com.tangem.domain.analytics.model.WalletBalanceState +import com.tangem.domain.analytics.repository.AnalyticsRepository +import com.tangem.domain.wallets.models.UserWalletId + +internal class DefaultAnalyticsRepository( + private val appPreferencesStore: AppPreferencesStore, +) : AnalyticsRepository { + + override suspend fun checkIsEventSent(eventId: String): Boolean { + val sentEvents = appPreferencesStore + .getObjectListSync(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY) + + return eventId in sentEvents + } + + override suspend fun setIsEventSent(eventId: String) { + appPreferencesStore.editData { mutablePreferences -> + val sentEvents = mutablePreferences.getObjectList(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY) + val updatedSentEvents = sentEvents.orEmpty() + eventId + + mutablePreferences.setObjectList(PreferencesKeys.SENT_ONE_TIME_EVENTS_KEY, updatedSentEvents) + } + } + + override suspend fun getWalletBalanceState(userWalletId: UserWalletId): WalletBalanceState? { + val walletsBalanceState = appPreferencesStore.getObjectMap( + key = PreferencesKeys.WALLETS_BALANCES_STATES_KEY, + ) + + return walletsBalanceState[userWalletId.stringValue] + } + + override suspend fun setWalletBalanceState(userWalletId: UserWalletId, balanceState: WalletBalanceState) { + appPreferencesStore.editData { + val walletsBalanceState = it.getObjectMap( + key = PreferencesKeys.WALLETS_BALANCES_STATES_KEY, + ) + val updatedWalletsBalanceState = walletsBalanceState.orEmpty() + .plus(pair = userWalletId.stringValue to balanceState) + + it.setObjectMap(PreferencesKeys.WALLETS_BALANCES_STATES_KEY, updatedWalletsBalanceState) + } + } +} \ No newline at end of file diff --git a/data/analytics/src/main/kotlin/com/tangem/data/analytics/di/AnalyticsDataModule.kt b/data/analytics/src/main/kotlin/com/tangem/data/analytics/di/AnalyticsDataModule.kt new file mode 100644 index 0000000000..95fcd5add2 --- /dev/null +++ b/data/analytics/src/main/kotlin/com/tangem/data/analytics/di/AnalyticsDataModule.kt @@ -0,0 +1,19 @@ +package com.tangem.data.analytics.di + +import com.tangem.data.analytics.DefaultAnalyticsRepository +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.analytics.repository.AnalyticsRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object AnalyticsDataModule { + + @Provides + fun provideAnalyticsRepository(appPreferencesStore: AppPreferencesStore): AnalyticsRepository { + return DefaultAnalyticsRepository(appPreferencesStore) + } +} \ No newline at end of file diff --git a/data/card/build.gradle.kts b/data/card/build.gradle.kts index 6015c1ed3a..872f5ce751 100644 --- a/data/card/build.gradle.kts +++ b/data/card/build.gradle.kts @@ -13,6 +13,10 @@ android { dependencies { implementation(deps.androidx.datastore) + implementation(deps.tangem.blockchain) { + exclude(module = "joda-time") + } + implementation(deps.hilt.android) kapt(deps.hilt.kapt) 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 a093b24ba0..97c5e7dc35 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 @@ -1,6 +1,7 @@ package com.tangem.data.card import com.tangem.TangemSdk +import com.tangem.blockchain.common.CommonSigner import com.tangem.common.UserCodeType import com.tangem.common.core.CardIdDisplayFormat import com.tangem.common.core.UserCodeRequestPolicy @@ -57,4 +58,16 @@ internal class DefaultCardSdkConfigRepository( } override fun isAccessCodeSavingEnabled(): Boolean = preferencesDataSource.shouldSaveAccessCodes + + override fun getCommonSigner(cardId: String?) = CommonSigner( + tangemSdk = sdk, + cardId = cardId, + initialMessage = null, + ) + + override fun isLinkedTerminal() = sdk.config.linkedTerminal + + override fun setLinkedTerminal(isLinked: Boolean?) { + sdk.config.linkedTerminal = isLinked + } } \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt index 0fed0929d9..8d3c6f18bc 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/DefaultSettingsRepository.kt @@ -1,16 +1,35 @@ package com.tangem.data.settings import com.tangem.data.source.preferences.PreferencesDataSource +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getSyncOrDefault +import com.tangem.datasource.local.preferences.utils.store import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext internal class DefaultSettingsRepository( private val preferencesDataSource: PreferencesDataSource, + private val appPreferencesStore: AppPreferencesStore, private val dispatchers: CoroutineDispatcherProvider, ) : SettingsRepository { override suspend fun shouldShowSaveUserWalletScreen(): Boolean { return withContext(dispatchers.io) { preferencesDataSource.shouldShowSaveUserWalletScreen } } + + override suspend fun isWalletScrollPreviewEnabled(): Boolean { + return appPreferencesStore.getSyncOrDefault( + key = PreferencesKeys.WALLETS_SCROLL_PREVIEW_KEY, + default = true, + ) + } + + override suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean) { + appPreferencesStore.store( + key = PreferencesKeys.WALLETS_SCROLL_PREVIEW_KEY, + value = isEnabled, + ) + } } \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt index 254235cf84..ba83b09d4d 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt @@ -21,10 +21,12 @@ internal object SettingsDataModule { @Singleton fun provideSettingsRepository( preferencesDataSource: PreferencesDataSource, + appPreferencesStore: AppPreferencesStore, dispatchers: CoroutineDispatcherProvider, ): SettingsRepository { return DefaultSettingsRepository( preferencesDataSource = preferencesDataSource, + appPreferencesStore = appPreferencesStore, dispatchers = dispatchers, ) } diff --git a/data/tokens/build.gradle.kts b/data/tokens/build.gradle.kts index 88d7471830..f183caa14f 100644 --- a/data/tokens/build.gradle.kts +++ b/data/tokens/build.gradle.kts @@ -45,4 +45,5 @@ dependencies { implementation(deps.jodatime) implementation(deps.timber) implementation(deps.retrofit) // For HttpException + implementation(deps.androidx.paging.runtime) } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt index 80ceefbdc9..db088b565c 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/di/TokensDataModule.kt @@ -1,21 +1,16 @@ package com.tangem.data.tokens.di import com.tangem.data.common.cache.CacheRegistry -import com.tangem.data.tokens.repository.DefaultCurrenciesRepository -import com.tangem.data.tokens.repository.DefaultMarketCryptoCurrencyRepository -import com.tangem.data.tokens.repository.DefaultNetworksRepository -import com.tangem.data.tokens.repository.DefaultQuotesRepository +import com.tangem.data.tokens.repository.* +import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.network.NetworksStatusesStore import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.quote.QuotesStore -import com.tangem.datasource.local.token.UserMarketCoinsStore +import com.tangem.datasource.local.token.AssetsStore import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.userwallet.UserWalletsStore -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository -import com.tangem.domain.tokens.repository.NetworksRepository -import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.tokens.repository.* import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -32,17 +27,19 @@ internal object TokensDataModule { @Singleton fun provideCurrenciesRepository( tangemTechApi: TangemTechApi, + tangemExpressApi: TangemExpressApi, userTokensStore: UserTokensStore, userWalletsStore: UserWalletsStore, - userMarketCoinsStore: UserMarketCoinsStore, + assetsStore: AssetsStore, cacheRegistry: CacheRegistry, dispatchers: CoroutineDispatcherProvider, ): CurrenciesRepository { return DefaultCurrenciesRepository( tangemTechApi = tangemTechApi, + tangemExpressApi = tangemExpressApi, userTokensStore = userTokensStore, userWalletsStore = userWalletsStore, - userMarketCoinsStore = userMarketCoinsStore, + assetsStore = assetsStore, cacheRegistry = cacheRegistry, dispatchers = dispatchers, ) @@ -88,9 +85,21 @@ internal object TokensDataModule { @Provides @Singleton - fun provideDefaultMarketCoinsRepository( - userMarketCoinsStore: UserMarketCoinsStore, - ): MarketCryptoCurrencyRepository { - return DefaultMarketCryptoCurrencyRepository(userMarketCoinsStore) + fun provideDefaultMarketCoinsRepository(assetsStore: AssetsStore): MarketCryptoCurrencyRepository { + return DefaultMarketCryptoCurrencyRepository(assetsStore) + } + + @Provides + @Singleton + fun providesTokensListRepository( + tangemTechApi: TangemTechApi, + dispatchers: CoroutineDispatcherProvider, + quotesRepository: QuotesRepository, + ): TokensListRepository { + return DefaultTokensListRepository( + tangemTechApi = tangemTechApi, + dispatchers = dispatchers, + quotesRepository = quotesRepository, + ) } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt new file mode 100644 index 0000000000..beb9f10b36 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsPagingSource.kt @@ -0,0 +1,77 @@ +package com.tangem.data.tokens.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.CoinsResponse +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.tokens.model.Token +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * A PagingSource responsible for retrieving tokens from the Tangem Tech API and adding to them quotes information. + * + * @property api Tangem Tech API + * @property quotesRepository Repository providing quotes data. + * @property dispatchers Coroutine dispatchers provider. + * @property searchText The search text used to filter tokens. + */ +internal class CoinsPagingSource( + private val api: TangemTechApi, + private val quotesRepository: QuotesRepository, + private val dispatchers: CoroutineDispatcherProvider, + private val searchText: String?, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(other = 1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(other = 1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 0 + + return com.tangem.utils.coroutines.runCatching(dispatchers.io) { + api.getCoins( + active = true, // TODO change when voting functionality is implemented + searchText = searchText, + offset = page * params.loadSize, + limit = params.loadSize, + ) + }.fold( + onSuccess = { response -> + val coinsIds = response.coins.map { coin -> + CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(coin.id), + suffix = CryptoCurrency.ID.Suffix.RawID(coin.id), + ) + } + val quotes = quotesRepository.getQuotesSync(currenciesIds = coinsIds.toSet(), refresh = false) + + LoadResult.Page( + data = CoinsResponseConverter.convert( + CoinsData( + response.coins, + response.imageHost, + quotes, + ), + ), + prevKey = if (page == 0) null else page.minus(other = 1), + nextKey = if (response.coins.isEmpty()) null else page.plus(other = 1), + ) + }, + onFailure = { LoadResult.Error(it) }, + ) + } +} + +data class CoinsData( + val coins: List, + val imageHost: String?, + val quotes: Set, +) \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt new file mode 100644 index 0000000000..7735b24528 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/paging/CoinsResponseConverter.kt @@ -0,0 +1,51 @@ +package com.tangem.data.tokens.paging + +import com.tangem.blockchain.common.Blockchain +import com.tangem.data.tokens.utils.getNetworkStandardType +import com.tangem.datasource.api.tangemTech.models.CoinsResponse +import com.tangem.domain.common.extensions.fromNetworkId +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.Token +import com.tangem.utils.converter.Converter + +/** + * Converter from data model [CoinsResponse] to list of domain models [Token] + */ +internal object CoinsResponseConverter : Converter> { + + override fun convert(value: CoinsData): List { + return value.coins.map { coin -> + val id = CryptoCurrency.ID( + CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + CryptoCurrency.ID.Body.NetworkId(coin.id), + CryptoCurrency.ID.Suffix.RawID(coin.id), + ) + val quote = value.quotes.find { it.rawCurrencyId == id.rawCurrencyId } + Token( + id = id.rawCurrencyId ?: coin.name, + name = coin.name, + symbol = coin.symbol, + iconUrl = getIconUrl(coin.id, value.imageHost), + isAvailable = coin.active, + networks = coin.networks.mapNotNull { network -> + val blockchain = Blockchain.fromNetworkId(network.networkId) ?: return@mapNotNull null + Token.Network( + networkId = network.networkId, + standardType = getNetworkStandardType(blockchain).name, + address = network.contractAddress, + iconUrl = getIconUrl(network.networkId, value.imageHost), + decimalCount = network.decimalCount?.toInt(), + ) + }, + quote = quote, + ) + } + } +} + +internal fun getIconUrl(id: String, imageHost: String? = null): String { + return "${imageHost ?: DEFAULT_IMAGE_HOST}large/$id.png" +} + +private const val DEFAULT_IMAGE_HOST = + "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/" \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index ca0202ae25..d927d3e04e 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -5,9 +5,14 @@ import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.tokens.utils.* import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE +import com.tangem.datasource.api.express.models.request.AssetsRequestBody +import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -import com.tangem.datasource.local.token.UserMarketCoinsStore +import com.tangem.datasource.local.token.AssetsStore import com.tangem.datasource.local.token.UserTokensStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.common.extensions.toCoinId @@ -28,12 +33,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -@Suppress("LargeClass") +@Suppress("LargeClass", "LongParameterList") internal class DefaultCurrenciesRepository( private val tangemTechApi: TangemTechApi, + private val tangemExpressApi: TangemExpressApi, private val userTokensStore: UserTokensStore, private val userWalletsStore: UserWalletsStore, - private val userMarketCoinsStore: UserMarketCoinsStore, + private val assetsStore: AssetsStore, private val cacheRegistry: CacheRegistry, private val dispatchers: CoroutineDispatcherProvider, ) : CurrenciesRepository { @@ -88,12 +94,14 @@ internal class DefaultCurrenciesRepository( val newCurrencies = (newCoins + filteredCurrencies).distinct() + val updatedResponse = savedCurrencies.copy( + tokens = savedCurrencies.tokens + newCurrencies.map(userTokensResponseFactory::createResponseToken), + ) storeAndPushTokens( userWalletId = userWalletId, - response = savedCurrencies.copy( - tokens = savedCurrencies.tokens + newCurrencies.map(userTokensResponseFactory::createResponseToken), - ), + response = updatedResponse, ) + fetchExchangeableUserMarketCoinsByIds(userWalletId, updatedResponse) } } @@ -374,14 +382,25 @@ internal class DefaultCurrenciesRepository( userTokens: UserTokensResponse, ) { try { - val networkIds = userTokens.tokens - .distinctBy { it.networkId } - .joinToString(separator = ",") { it.networkId } - val response = tangemTechApi.getCoins(networkIds = networkIds, exchangeable = true) + val tokensList = userTokens.tokens + .map { + LeastTokenInfo( + contractAddress = it.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE, + network = it.networkId, + ) + } - userMarketCoinsStore.store(userWalletId, response) + if (tokensList.isNotEmpty()) { + val response = tangemExpressApi.getAssets( + AssetsRequestBody( + tokensList = tokensList, + ), + ) + + assetsStore.store(userWalletId, response.getOrThrow()) + } } catch (e: Throwable) { - Timber.e(e, "Unable to fetch user market coins for: ${userWalletId.stringValue}") + Timber.e(e, "Unable to fetch assets for: ${userWalletId.stringValue}") } } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultMarketCryptoCurrencyRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultMarketCryptoCurrencyRepository.kt index 2211aa96cd..84188ea775 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultMarketCryptoCurrencyRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultMarketCryptoCurrencyRepository.kt @@ -1,28 +1,21 @@ package com.tangem.data.tokens.repository -import com.tangem.blockchain.common.Blockchain -import com.tangem.datasource.local.token.UserMarketCoinsStore -import com.tangem.domain.common.extensions.toNetworkId +import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE +import com.tangem.datasource.local.token.AssetsStore import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository import com.tangem.domain.wallets.models.UserWalletId class DefaultMarketCryptoCurrencyRepository( - private val userMarketCoinsStore: UserMarketCoinsStore, + private val assetsStore: AssetsStore, ) : MarketCryptoCurrencyRepository { - override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Boolean { - val blockchain = Blockchain.fromId(cryptoCurrencyId.rawNetworkId) - val apiNetworkId = blockchain.toNetworkId() - return userMarketCoinsStore.getSyncOrNull(userWalletId)?.coins - ?.firstOrNull { it.id == cryptoCurrencyId.rawCurrencyId } - ?.networks - ?.firstOrNull { - if (it.contractAddress != null) { - it.networkId == apiNetworkId && it.contractAddress == cryptoCurrencyId.contractAddress - } else { - it.networkId == apiNetworkId - } - }?.exchangeable ?: false + override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { + val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE + + return assetsStore.getSyncOrNull(userWalletId)?.find { + it.network == cryptoCurrency.network.backendId && + it.contractAddress.equals(contractAddress, ignoreCase = true) + }?.exchangeAvailable ?: false } } \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt index cdd04c62ce..7aa81fc78c 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultQuotesRepository.kt @@ -67,6 +67,14 @@ internal class DefaultQuotesRepository( } } + override suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote { + return withContext(dispatchers.io) { + val quote = quotesStore.getSync(setOf(currencyId)).firstOrNull() + requireNotNull(quote) { "Unable to get quote for $currencyId" } + quotesConverter.convert(quote) + } + } + private suspend fun fetchExpiredQuotes( currenciesIds: Set, appCurrencyId: String, diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokensListRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokensListRepository.kt new file mode 100644 index 0000000000..d4f2d9a9a2 --- /dev/null +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultTokensListRepository.kt @@ -0,0 +1,45 @@ +package com.tangem.data.tokens.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.tangem.data.tokens.paging.CoinsPagingSource +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.domain.tokens.model.Token +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.tokens.repository.TokensListRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.Flow + +/** + * Default repository implementation for managing operations related to a complete set of tokens + * + * @property tangemTechApi Tangem Tech API + * @property dispatchers coroutine dispatchers provider + * @property quotesRepository responsible for providing cryptocurrency quotes data + * + */ +internal class DefaultTokensListRepository( + private val tangemTechApi: TangemTechApi, + private val dispatchers: CoroutineDispatcherProvider, + private val quotesRepository: QuotesRepository, +) : TokensListRepository { + + override fun getTokens(searchText: String?): Flow> { + return Pager( + config = PagingConfig( + pageSize = 100, + prefetchDistance = 70, + enablePlaceholders = false, + ), + pagingSourceFactory = { + CoinsPagingSource( + api = tangemTechApi, + dispatchers = dispatchers, + searchText = searchText, + quotesRepository = quotesRepository, + ) + }, + ).flow + } +} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt index 5e87555133..8b2a7175c6 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/NetworkOperations.kt @@ -2,6 +2,7 @@ package com.tangem.data.tokens.utils import com.tangem.blockchain.common.Blockchain import com.tangem.domain.common.DerivationStyleProvider +import com.tangem.domain.common.extensions.toNetworkId import com.tangem.domain.tokens.model.Network import timber.log.Timber @@ -21,9 +22,11 @@ internal fun getNetwork( return Network( id = Network.ID(blockchain.id), + backendId = blockchain.toNetworkId(), name = blockchain.fullName, isTestnet = blockchain.isTestnet(), derivationPath = getNetworkDerivationPath(blockchain, extraDerivationPath, derivationStyleProvider), + currencySymbol = blockchain.currency, standardType = getNetworkStandardType(blockchain), ) } @@ -50,7 +53,7 @@ private fun getNetworkDerivationPath( } } -private fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType { +internal fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType { return when (blockchain) { Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.StandardType.ERC20 Blockchain.BSC, Blockchain.BSCTestnet -> Network.StandardType.BEP20 diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt index 13dcd79a67..54f468860b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/TokensOperations.kt @@ -4,7 +4,6 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.IconsUtil import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.domain.common.extensions.toCoinId -import com.tangem.domain.common.extensions.toNetworkId import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrency.ID import com.tangem.domain.tokens.model.Network @@ -55,8 +54,7 @@ internal fun getTokenIconUrl(blockchain: Blockchain, token: SdkToken): String? { internal fun getCoinIconUrl(blockchain: Blockchain): String? { val coinId = when (blockchain) { Blockchain.Unknown -> null - Blockchain.TerraV1, Blockchain.TerraV2, Blockchain.Near -> blockchain.toCoinId() - else -> blockchain.toNetworkId() + else -> blockchain.toCoinId() } return coinId?.let(::getTokenIconUrlFromDefaultHost) diff --git a/domain/analytics/.gitignore b/domain/analytics/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/domain/analytics/.gitignore @@ -0,0 +1 @@ +/build diff --git a/domain/analytics/build.gradle.kts b/domain/analytics/build.gradle.kts new file mode 100644 index 0000000000..e5e871ea2f --- /dev/null +++ b/domain/analytics/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + + /** Project - Domain */ + implementation(projects.core.utils) + implementation(projects.domain.core) + implementation(projects.domain.wallets.models) +} \ No newline at end of file diff --git a/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/CheckIsWalletToppedUpUseCase.kt b/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/CheckIsWalletToppedUpUseCase.kt new file mode 100644 index 0000000000..3fc031a509 --- /dev/null +++ b/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/CheckIsWalletToppedUpUseCase.kt @@ -0,0 +1,56 @@ +package com.tangem.domain.analytics + +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.domain.analytics.model.WalletBalanceState +import com.tangem.domain.analytics.repository.AnalyticsRepository +import com.tangem.domain.wallets.models.UserWalletId + +/** + * Check if wallet has been topped up now. + * */ +class CheckIsWalletToppedUpUseCase( + private val analyticsRepository: AnalyticsRepository, +) { + + /** + * Check if wallet has been topped up now. + * + * @param userWalletId User wallet ID. + * @param balanceState Current wallet balance. + * + * @return Either [Throwable] or [Boolean] representing if wallet has been topped up. + * */ + suspend operator fun invoke( + userWalletId: UserWalletId, + balanceState: WalletBalanceState, + ): Either = either { + val storedBalanceState = getStoredBalanceState(userWalletId) + + when { + storedBalanceState == WalletBalanceState.ToppedUp -> false // already topped up + storedBalanceState == null && balanceState == WalletBalanceState.ToppedUp -> { + setBalanceState(userWalletId, balanceState) + false // already topped up + } + else -> { + setBalanceState(userWalletId, balanceState) + balanceState == WalletBalanceState.ToppedUp + } + } + } + + private suspend fun Raise.getStoredBalanceState(userWalletId: UserWalletId) = catch( + block = { analyticsRepository.getWalletBalanceState(userWalletId) }, + catch = ::raise, + ) + + private suspend fun Raise.setBalanceState(userWalletId: UserWalletId, balanceState: WalletBalanceState) { + catch( + block = { analyticsRepository.setWalletBalanceState(userWalletId, balanceState) }, + catch = ::raise, + ) + } +} \ No newline at end of file diff --git a/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/model/WalletBalanceState.kt b/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/model/WalletBalanceState.kt new file mode 100644 index 0000000000..e9cde413a2 --- /dev/null +++ b/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/model/WalletBalanceState.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.analytics.model + +enum class WalletBalanceState { + ToppedUp, Empty, Error, +} \ No newline at end of file diff --git a/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/repository/AnalyticsRepository.kt b/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/repository/AnalyticsRepository.kt new file mode 100644 index 0000000000..2e80e2259c --- /dev/null +++ b/domain/analytics/src/main/kotlin/com/tangem/domain/analytics/repository/AnalyticsRepository.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.analytics.repository + +import com.tangem.domain.analytics.model.WalletBalanceState +import com.tangem.domain.wallets.models.UserWalletId + +interface AnalyticsRepository { + + suspend fun checkIsEventSent(eventId: String): Boolean + + suspend fun setIsEventSent(eventId: String) + + suspend fun getWalletBalanceState(userWalletId: UserWalletId): WalletBalanceState? + + suspend fun setWalletBalanceState(userWalletId: UserWalletId, balanceState: WalletBalanceState) +} \ No newline at end of file diff --git a/domain/card/build.gradle.kts b/domain/card/build.gradle.kts index 71512156cd..342a410f5a 100644 --- a/domain/card/build.gradle.kts +++ b/domain/card/build.gradle.kts @@ -19,4 +19,8 @@ dependencies { implementation(deps.tangem.card.core) + implementation(deps.tangem.blockchain) { + exclude(module = "joda-time") + } + } \ No newline at end of file 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 1190a2952d..47d13a5a8c 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 @@ -1,6 +1,7 @@ package com.tangem.domain.card.repository import com.tangem.TangemSdk +import com.tangem.blockchain.common.CommonSigner import com.tangem.domain.models.scan.ProductType /** @@ -28,4 +29,13 @@ interface CardSdkConfigRepository { /** Check if access code saving is enabled */ fun isAccessCodeSavingEnabled(): Boolean + + /** Get common signer by [cardId] */ + fun getCommonSigner(cardId: String?): CommonSigner + + /** Check if linked terminal is enabled */ + fun isLinkedTerminal(): Boolean? + + /** Set linked terminal by [isLinked] */ + fun setLinkedTerminal(isLinked: Boolean?) } \ No newline at end of file diff --git a/domain/legacy/src/main/assets/contract_methods.json b/domain/legacy/src/main/assets/contract_methods.json index e07b98657c..f7bad0ed28 100644 --- a/domain/legacy/src/main/assets/contract_methods.json +++ b/domain/legacy/src/main/assets/contract_methods.json @@ -121,5 +121,25 @@ }, "0xd0e30db0": { "name":"deposit" + }, + "0x9f50973e": { + "info":"swapExactETCForTokens", + "source":"https://etc.blockscout.com/tx/0xc748777532541e51368089c71c93cb0e410bf93c13908bea2d07ecbbb65aa839", + "name":"swap" + }, + "0x3417c606": { + "info":"swapETCForExactTokens", + "source":"https://etc.blockscout.com/tx/0xb2c5a4418112c0b29654642f9c724bf7b2f9056fc6dd2c22209ed89fe982f0af", + "name":"swap" + }, + "0x65f705d3": { + "info":"swapExactTokensForETC", + "source":"https://etc.blockscout.com/tx/0xdd5d372e8d770e436bd44367bbc64b57d02e7339a1f1b849f7a995a57c65f3dd", + "name":"swap" + }, + "0xb4bc722b": { + "info":"swapTokensForExactETC", + "source":"https://etc.blockscout.com/tx/0xd79e03ca6b71529c94d576ff78c55b4371e2ef3ff8f9a47007995e5b9e77f879", + "name":"swap" } } 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 15010e8b52..d1a57e7726 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/walletmanager/DefaultWalletManagersFacade.kt @@ -10,6 +10,7 @@ import com.tangem.blockchain.blockchains.solana.RentProvider import com.tangem.blockchain.common.* import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.common.txhistory.TransactionHistoryRequest import com.tangem.blockchain.extensions.Result @@ -35,6 +36,7 @@ import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow import timber.log.Timber import java.math.BigDecimal +import java.util.EnumSet @Suppress("LargeClass") // FIXME: Move to its own module and make internal @@ -431,6 +433,54 @@ class DefaultWalletManagersFacade( ) } + override suspend fun validateTransaction( + amount: Amount, + fee: Amount?, + userWalletId: UserWalletId, + network: Network, + ): EnumSet? { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + return walletManager?.validateTransaction(amount, fee) + } + + override suspend fun createTransaction( + amount: Amount, + fee: Fee, + memo: String?, + destination: String, + userWalletId: UserWalletId, + network: Network, + ): TransactionData? { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + + return walletManager?.createTransaction(amount, fee, destination) + } + + override suspend fun sendTransaction( + txData: TransactionData, + signer: CommonSigner, + userWalletId: UserWalletId, + network: Network, + ): SimpleResult { + val blockchain = Blockchain.fromId(network.id.value) + val walletManager = getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = blockchain, + derivationPath = network.derivationPath.value, + ) + return (walletManager as TransactionSender).send(txData, signer) + } + 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 8794d460f7..dba5d089eb 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 @@ -2,13 +2,13 @@ package com.tangem.domain.walletmanager import arrow.core.Either import com.tangem.blockchain.blockchains.solana.RentProvider -import com.tangem.blockchain.common.Amount -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.WalletManager +import com.tangem.blockchain.common.* import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.extensions.SimpleResult import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning @@ -19,6 +19,7 @@ import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow import java.math.BigDecimal +import java.util.EnumSet // TODO: Move to its own module /** @@ -164,4 +165,57 @@ interface WalletManagersFacade { userWalletId: UserWalletId, network: Network, ): Result? + + /** + * Validates transaction + * + * @param amount of transaction + * @param fee of transaction + * @param userWalletId selected wallet id + * @param network network of currency + */ + @Deprecated("Will be removed in future") + suspend fun validateTransaction( + amount: Amount, + fee: Amount?, + userWalletId: UserWalletId, + network: Network, + ): EnumSet? + + /** + * Creates transaction [TransactionData] + * + * @param amount of transaction + * @param fee of transaction + * @param memo of transaction optional + * @param destination address + * @param userWalletId selected wallet id + * @param network network of currency + */ + @Suppress("LongParameterList") + @Deprecated("Will be removed in future") + suspend fun createTransaction( + amount: Amount, + fee: Fee, + memo: String?, + destination: String, + userWalletId: UserWalletId, + network: Network, + ): TransactionData? + + /** + * Sends transaction + * + * @param txData transaction data + * @param signer card signer + * @param userWalletId selected wallet id + * @param network network of currency + */ + @Deprecated("Will be removed in future") + suspend fun sendTransaction( + txData: TransactionData, + signer: CommonSigner, + userWalletId: UserWalletId, + network: Network, + ): SimpleResult } \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/IsWalletsScrollPreviewEnabled.kt b/domain/settings/src/main/java/com/tangem/domain/settings/IsWalletsScrollPreviewEnabled.kt new file mode 100644 index 0000000000..fc45e34aa0 --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/IsWalletsScrollPreviewEnabled.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.settings + +import com.tangem.domain.settings.repositories.SettingsRepository + +/** + * Checks if wallets scroll preview is enabled + * + * @property settingsRepository settings repository + * +[REDACTED_AUTHOR] + */ +class IsWalletsScrollPreviewEnabled(private val settingsRepository: SettingsRepository) { + + suspend operator fun invoke(): Boolean = settingsRepository.isWalletScrollPreviewEnabled() +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/NeverToShowWalletsScrollPreview.kt b/domain/settings/src/main/java/com/tangem/domain/settings/NeverToShowWalletsScrollPreview.kt new file mode 100644 index 0000000000..4080a08bc9 --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/NeverToShowWalletsScrollPreview.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.settings + +import com.tangem.domain.settings.repositories.SettingsRepository + +/** + * Never to show wallets scroll preview + * + * @property settingsRepository settings repository + * +[REDACTED_AUTHOR] + */ +class NeverToShowWalletsScrollPreview( + private val settingsRepository: SettingsRepository, +) { + + suspend operator fun invoke() = settingsRepository.setWalletScrollPreviewAvailability(isEnabled = false) +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt index cdfe2702da..7dd4507554 100644 --- a/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt +++ b/domain/settings/src/main/java/com/tangem/domain/settings/repositories/SettingsRepository.kt @@ -3,4 +3,8 @@ package com.tangem.domain.settings.repositories interface SettingsRepository { suspend fun shouldShowSaveUserWalletScreen(): Boolean + + suspend fun isWalletScrollPreviewEnabled(): Boolean + + suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean) } \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index c146658684..8fabb669f0 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -22,6 +22,9 @@ dependencies { /** Project - Other */ implementation(projects.core.utils) + /** Android - Other */ + implementation(deps.androidx.paging.runtime) + /** Utils */ implementation(deps.jodatime) implementation(deps.reKotlin) diff --git a/domain/tokens/models/build.gradle.kts b/domain/tokens/models/build.gradle.kts index e9075cefa0..a354fa4700 100644 --- a/domain/tokens/models/build.gradle.kts +++ b/domain/tokens/models/build.gradle.kts @@ -12,4 +12,7 @@ android { dependencies { implementation(projects.domain.txhistory.models) implementation(projects.core.analytics.models) + implementation(deps.tangem.blockchain) { + exclude(module = "joda-time") + } } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt index 2bc545ba77..17274cfbe9 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Network.kt @@ -11,6 +11,7 @@ import kotlinx.parcelize.Parcelize * (e.g., ERC20, BEP20). * * @property id The unique identifier of the network. + * @property backendId The name of this network in the Tangem backend. * @property name The human-readable name of the network, such as "Ethereum" or "Bitcoin". * @property derivationPath The path used to derive keys for this network. * @property isTestnet Indicates whether the network is a test network or a main network. @@ -19,7 +20,9 @@ import kotlinx.parcelize.Parcelize @Parcelize data class Network( val id: ID, + val backendId: String, val name: String, + val currencySymbol: String, val derivationPath: DerivationPath, val isTestnet: Boolean, val standardType: StandardType, 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 899574d5e4..9f5dfbe387 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 @@ -3,14 +3,17 @@ package com.tangem.domain.tokens.model import java.math.BigDecimal /** - * Represents a financial quote for a specific cryptocurrency, including its fiat exchange rate and price change. + * Represents financial information for a specific cryptocurrency, including its fiat exchange rate and price change. * - * @property rawCurrencyId The unique identifier of the token for which the quote is provided. + * @property rawCurrencyId The unique identifier of the cryptocurrency for which the financial information is provided. * @property fiatRate The current fiat exchange rate for the cryptocurrency. * @property priceChange The price change for the cryptocurrency. + * @property values The values representing the cryptocurrency's price changes over a 24-hour period, + * suitable for chart plotting. */ data class Quote( val rawCurrencyId: String, val fiatRate: BigDecimal, val priceChange: BigDecimal, + val values: List? = null, ) \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt new file mode 100644 index 0000000000..ed26e356b8 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/Token.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.tokens.model + +/** + * Represents a domain model for a token (as part of a general list of tokens). + * + * @property id The unique identifier of the token. + * @property networks List of networks associated with the token. + * @property isAvailable Indicates whether this token is supported in Tangem app. + * @property quote Equivalent prices for the token. + * @property name The name of the token. + * @property symbol The brief name of the token, e.g., "BTC". + * @property iconUrl URL of the token's icon. + */ +data class Token( + val id: String, + val networks: List, + val isAvailable: Boolean, + val quote: Quote?, + val name: String, + val symbol: String, + val iconUrl: String, +) { + + /** + * Represents a domain model for a network associated with a token. + * + * @property networkId The unique identifier of the network. + * @property standardType The type of blockchain associated with the network. + * @property address The contract address of the token on the current network. + * It is null for the main currencies of the network. + * @property iconUrl URL of the network's icon. + * @property decimalCount The decimal count associated with the token on the network. + */ + data class Network( + val networkId: String, + val standardType: String, + val address: String?, + val iconUrl: String, + val decimalCount: Int?, + ) +} \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt new file mode 100644 index 0000000000..a2161adc46 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/models/analytics/TokenExchangeAnalyticsEvent.kt @@ -0,0 +1,34 @@ +package com.tangem.domain.tokens.models.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +class TokenExchangeAnalyticsEvent( + event: String, + params: Map = mapOf(), +) : AnalyticsEvent("Token", event, params, null) { + + class CexTxStatusOpened(token: String) : TokenScreenAnalyticsEvent( + event = "ChangeNow Status Opened", + params = mapOf("Token" to token), + ) + + class CexTxStatusChanged(token: String, status: String) : TokenScreenAnalyticsEvent( + event = "ChangeNow Status", + params = mapOf("Token" to token, "Status" to status), + ) + + class GoToProviderStatus(token: String) : TokenScreenAnalyticsEvent( + event = "Button - Go To Provider", + params = mapOf("Token" to token, "Place" to "Status"), + ) + + class GoToProviderKYC(token: String) : TokenScreenAnalyticsEvent( + event = "Button - Go To Provider", + params = mapOf("Token" to token, "Place" to "KYC"), + ) + + class GoToProviderFail(token: String) : TokenScreenAnalyticsEvent( + event = "Button - Go To Provider", + params = mapOf("Token" to token, "Place" to "Fail"), + ) +} \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/utils/BigDecimalUtils.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/utils/BigDecimalUtils.kt new file mode 100644 index 0000000000..fe93d1d8ec --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/utils/BigDecimalUtils.kt @@ -0,0 +1,24 @@ +package com.tangem.domain.tokens.utils + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.Token +import com.tangem.domain.tokens.model.CryptoCurrency +import java.math.BigDecimal + +/** Converts `BigDecimal` [cryptoCurrency] to [Amount] */ +fun BigDecimal.convertToAmount(cryptoCurrency: CryptoCurrency) = Amount( + currencySymbol = cryptoCurrency.symbol, + value = this, + decimals = cryptoCurrency.decimals, + type = when (cryptoCurrency) { + is CryptoCurrency.Coin -> AmountType.Coin + is CryptoCurrency.Token -> AmountType.Token( + token = Token( + symbol = cryptoCurrency.symbol, + contractAddress = cryptoCurrency.contractAddress, + decimals = cryptoCurrency.decimals, + ), + ) + }, +) \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyActionsUseCase.kt index 55d6d6adf8..87dee2d3d9 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,5 +1,6 @@ package com.tangem.domain.tokens +import com.tangem.domain.common.CardTypesResolver import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.tokens.model.CryptoCurrency @@ -32,10 +33,7 @@ class GetCryptoCurrencyActionsUseCase( ) { @OptIn(ExperimentalCoroutinesApi::class) - suspend operator fun invoke( - userWallet: UserWallet, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): Flow { + operator fun invoke(userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus): Flow { val operations = CurrenciesStatusesOperations( currenciesRepository = currenciesRepository, quotesRepository = quotesRepository, @@ -43,19 +41,26 @@ class GetCryptoCurrencyActionsUseCase( userWalletId = userWallet.walletId, ) val networkId = cryptoCurrencyStatus.currency.network.id - val networkFlow = if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) { - operations.getNetworkCoinForSingleWalletWithTokenFlow(networkId) - } else if (!userWallet.isMultiCurrency) { - operations.getPrimaryCurrencyStatusFlow() - } else { - operations.getNetworkCoinFlow(networkId, cryptoCurrencyStatus.currency.network.derivationPath) - } - return networkFlow.mapLatest { maybeCoinStatus -> - createTokenActionsState( - userWalletId = userWallet.walletId, - coinStatus = maybeCoinStatus.getOrNull(), - cryptoCurrencyStatus = cryptoCurrencyStatus, - ) + + return flow { + val networkFlow = if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) { + operations.getNetworkCoinForSingleWalletWithTokenFlow(networkId) + } else if (!userWallet.isMultiCurrency) { + operations.getPrimaryCurrencyStatusFlow() + } else { + operations.getNetworkCoinFlow(networkId, cryptoCurrencyStatus.currency.network.derivationPath) + } + + val flow = networkFlow.mapLatest { maybeCoinStatus -> + createTokenActionsState( + userWalletId = userWallet.walletId, + coinStatus = maybeCoinStatus.getOrNull(), + cryptoCurrencyStatus = cryptoCurrencyStatus, + cardTypesResolver = userWallet.scanResponse.cardTypesResolver, + ) + } + + emitAll(flow) }.flowOn(dispatchers.io) } @@ -63,11 +68,17 @@ class GetCryptoCurrencyActionsUseCase( userWalletId: UserWalletId, coinStatus: CryptoCurrencyStatus?, cryptoCurrencyStatus: CryptoCurrencyStatus, + cardTypesResolver: CardTypesResolver, ): TokenActionsState { return TokenActionsState( walletId = userWalletId, cryptoCurrencyStatus = cryptoCurrencyStatus, - states = createListOfActions(userWalletId, coinStatus, cryptoCurrencyStatus), + states = createListOfActions( + userWalletId, + coinStatus, + cryptoCurrencyStatus, + cardTypesResolver, + ), ) } @@ -79,6 +90,7 @@ class GetCryptoCurrencyActionsUseCase( userWalletId: UserWalletId, coinStatus: CryptoCurrencyStatus?, cryptoCurrencyStatus: CryptoCurrencyStatus, + cardTypesResolver: CardTypesResolver, ): List { val cryptoCurrency = cryptoCurrencyStatus.currency if (cryptoCurrencyStatus.value is CryptoCurrencyStatus.MissedDerivation) { @@ -94,12 +106,8 @@ class GetCryptoCurrencyActionsUseCase( // copy address activeList.add(TokenActionsState.ActionState.CopyAddress(true)) - // buy - if (rampManager.availableForBuy(cryptoCurrency)) { - activeList.add(TokenActionsState.ActionState.Buy(true)) - } else { - disabledList.add(TokenActionsState.ActionState.Buy(false)) - } + // receive + activeList.add(TokenActionsState.ActionState.Receive(true)) // send if (isSendDisabled(cryptoCurrencyStatus = cryptoCurrencyStatus, coinStatus = coinStatus)) { @@ -108,16 +116,23 @@ class GetCryptoCurrencyActionsUseCase( activeList.add(TokenActionsState.ActionState.Send(true)) } - // receive - activeList.add(TokenActionsState.ActionState.Receive(true)) - + val isMulticurrencyWallet = cardTypesResolver.isTangemWallet() || cardTypesResolver.isWallet2() // swap - if (marketCryptoCurrencyRepository.isExchangeable(userWalletId, cryptoCurrency.id)) { + if (isMulticurrencyWallet && + marketCryptoCurrencyRepository.isExchangeable(userWalletId, cryptoCurrency) + ) { activeList.add(TokenActionsState.ActionState.Swap(true)) } else { disabledList.add(TokenActionsState.ActionState.Swap(false)) } + // buy + if (rampManager.availableForBuy(cryptoCurrency)) { + activeList.add(TokenActionsState.ActionState.Buy(true)) + } else { + disabledList.add(TokenActionsState.ActionState.Buy(false)) + } + // sell if (rampManager.availableForSell(cryptoCurrency)) { activeList.add(TokenActionsState.ActionState.Sell(true)) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt new file mode 100644 index 0000000000..ffe6e79c3b --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusSyncUseCase.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.tokens + +import arrow.core.Either +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.error.mapper.mapToTokenListError +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations +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 com.tangem.utils.coroutines.CoroutineDispatcherProvider + +class GetCryptoCurrencyStatusSyncUseCase( + internal val currenciesRepository: CurrenciesRepository, + internal val quotesRepository: QuotesRepository, + internal val networksRepository: NetworksRepository, + internal val dispatchers: CoroutineDispatcherProvider, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): Either { + val operations = CurrenciesStatusesOperations( + userWalletId = userWalletId, + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + ) + + return operations.getCurrencyStatusSync(cryptoCurrencyId) + .mapLeft { error -> error.mapToTokenListError() } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusesSyncUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusesSyncUseCase.kt new file mode 100644 index 0000000000..cdf58b3080 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrencyStatusesSyncUseCase.kt @@ -0,0 +1,32 @@ +package com.tangem.domain.tokens + +import arrow.core.Either +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.error.mapper.mapToTokenListError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations +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 com.tangem.utils.coroutines.CoroutineDispatcherProvider + +class GetCryptoCurrencyStatusesSyncUseCase( + internal val currenciesRepository: CurrenciesRepository, + internal val quotesRepository: QuotesRepository, + internal val networksRepository: NetworksRepository, + internal val dispatchers: CoroutineDispatcherProvider, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either> { + val operations = CurrenciesStatusesOperations( + userWalletId = userWalletId, + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + ) + + return operations.getCurrenciesStatusesSync() + .mapLeft { error -> error.mapToTokenListError() } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetGlobalTokenListUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetGlobalTokenListUseCase.kt new file mode 100644 index 0000000000..74ddd5b4e4 --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetGlobalTokenListUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.tokens + +import androidx.paging.PagingData +import com.tangem.domain.tokens.model.Token +import com.tangem.domain.tokens.repository.TokensListRepository +import kotlinx.coroutines.flow.Flow + +/** + * Use case for retrieving a complete set of tokens with their quotes (filtered by a search text if provided). + * + * @property repository The repository responsible for fetching and preparing the list of tokens with their quotes. + */ +class GetGlobalTokenListUseCase(private val repository: TokensListRepository) { + + /** + * @param searchText The text used for filtering the list of tokens (not required). + * @return A [Flow] emitting [PagingData] containing the tokens with their quotes. + */ + operator fun invoke(searchText: String?): Flow> { + return repository.getTokens(searchText = searchText?.ifBlank(defaultValue = { null })) + } +} \ 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 9bce6f0d35..a86698763b 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 @@ -67,6 +67,46 @@ internal class CurrenciesStatusesOperations( } } + suspend fun getCurrenciesStatusesSync(): Either> { + return either { + catch( + block = { + val nonEmptyCurrencies = + currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId).toNonEmptyListOrNull() + ?: return emptyList().right() + val (networks, currenciesIds) = getIds(nonEmptyCurrencies) + val quotes = quotesRepository.getQuotesSync(currenciesIds, false).right() + val networkStatuses = + networksRepository.getNetworkStatusesSync(userWalletId, networks, false).right() + return createCurrenciesStatuses(nonEmptyCurrencies, quotes, networkStatuses) + }, + catch = { raise(Error.DataError(it)) }, + ) + } + } + + suspend fun getCurrencyStatusSync(cryptoCurrencyId: CryptoCurrency.ID): Either { + return either { + catch( + block = { + val currency = + currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId, cryptoCurrencyId) + val quotes = quotesRepository.getQuoteSync(cryptoCurrencyId).right() + val networkStatuses = + networksRepository.getNetworkStatusesSync( + userWalletId, + setOf(currency.network), + false, + ).firstOrNull { + it.network == currency.network + }.right() + return createCurrencyStatus(currency, quotes, networkStatuses) + }, + catch = { raise(Error.DataError(it)) }, + ) + } + } + fun getCardCurrenciesStatusesFlow(): Flow>> { return flow { val nonEmptyCurrencies = recover( diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/MarketCryptoCurrencyRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/MarketCryptoCurrencyRepository.kt index 0186226918..0e0d223a53 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/MarketCryptoCurrencyRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/MarketCryptoCurrencyRepository.kt @@ -8,5 +8,5 @@ import com.tangem.domain.wallets.models.UserWalletId */ interface MarketCryptoCurrencyRepository { - suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Boolean + suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt index a44b1e6c99..4c38eda580 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/QuotesRepository.kt @@ -29,4 +29,6 @@ interface QuotesRepository { * @return A [Flow] emitting a set of quotes corresponding to the specified cryptocurrencies. */ suspend fun getQuotesSync(currenciesIds: Set, refresh: Boolean): Set + + suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote } \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt new file mode 100644 index 0000000000..96db81b6af --- /dev/null +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/repository/TokensListRepository.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.tokens.repository + +import androidx.paging.PagingData +import com.tangem.domain.tokens.model.Token +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface for managing operations related to a complete set of tokens + * (not associated with any specific card). + * */ +interface TokensListRepository { + + /** + * Retrieves a list of available tokens with quotes, can be filtered based on the provided search text. + * + * @param searchText The search text used to filter tokens. + * @return A [Flow] emitting [PagingData] containing the tokens with quotes matching the search criteria. + */ + fun getTokens(searchText: String?): Flow> +} \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt index 369bb8b5b3..522878e215 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt @@ -18,6 +18,8 @@ internal object MockNetworks { name = "Network One", isTestnet = false, standardType = Network.StandardType.ERC20, + backendId = "network1", + currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, ) @@ -26,6 +28,8 @@ internal object MockNetworks { name = "Network Two", isTestnet = false, standardType = Network.StandardType.ERC20, + backendId = "network1", + currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, ) @@ -34,6 +38,8 @@ internal object MockNetworks { name = "Network Three", isTestnet = false, standardType = Network.StandardType.ERC20, + backendId = "network1", + currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, ) diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt index 6d1c1e1a27..ae960f191a 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/repository/MockQuotesRepository.kt @@ -20,4 +20,9 @@ internal class MockQuotesRepository( override suspend fun getQuotesSync(currenciesIds: Set, refresh: Boolean): Set { return getQuotesUpdates(currenciesIds).first() } + + override suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote { + return quotes.map { it.getOrElse { e -> throw e } }.first() + .first { it.rawCurrencyId == currencyId.rawCurrencyId } + } } \ No newline at end of file diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index 8da795757a..571b6c0f50 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -14,10 +14,15 @@ dependencies { implementation(projects.core.utils) + /** Tangem SDKs */ + implementation(deps.tangem.card.core) implementation(deps.tangem.blockchain) + implementation(projects.domain.models) implementation(projects.domain.legacy) implementation(projects.domain.wallets.models) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) + implementation(projects.domain.demo) + implementation(projects.domain.card) } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt new file mode 100644 index 0000000000..8ed9705902 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SendTransactionError.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.transaction.error + +sealed class SendTransactionError { + + object DemoCardError : SendTransactionError() + + data class DataError(val message: String?) : SendTransactionError() + + data class NetworkError(val message: String?) : SendTransactionError() + + data class BlockchainSdkError(val code: Int, val cause: Throwable?) : SendTransactionError() + object UserCancelledError : SendTransactionError() + data class TangemSdkError(val code: Int, val cause: Throwable?) : SendTransactionError() + data class UnknownError(val ex: Exception? = null) : SendTransactionError() + + companion object { + const val USER_CANCELLED_ERROR_CODE = 50002 + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6860e413c9 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SendTransactionUseCase.kt @@ -0,0 +1,95 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.common.BlockchainSdkError +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.blockchain.network.ResultChecker +import com.tangem.common.core.TangemSdkError +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.TapWorkarounds.isStart2Coin +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.tokens.model.Network +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_CANCELLED_ERROR_CODE +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet + +class SendTransactionUseCase( + private val isDemoCardUseCase: IsDemoCardUseCase, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val walletManagersFacade: WalletManagersFacade, +) { + suspend operator fun invoke( + txData: TransactionData, + userWallet: UserWallet, + network: Network, + ): Either { + val signer = cardSdkConfigRepository.getCommonSigner( + userWallet.cardId, + ) + + val linkedTerminal = cardSdkConfigRepository.isLinkedTerminal() + if (userWallet.scanResponse.card.isStart2Coin) { + cardSdkConfigRepository.setLinkedTerminal(false) + } + val sendResult = try { + if (isDemoCardUseCase(cardId = userWallet.cardId)) { + SendTransactionError.DemoCardError.left() + } else { + walletManagersFacade.sendTransaction( + txData = txData, + signer = signer, + userWalletId = userWallet.walletId, + network = network, + ).right() + } + } catch (ex: Exception) { + cardSdkConfigRepository.setLinkedTerminal(linkedTerminal) + SendTransactionError.DataError(ex.message).left() + } + + cardSdkConfigRepository.setLinkedTerminal(linkedTerminal) + return sendResult.fold( + ifRight = { result -> + when (result) { + is SimpleResult.Success -> true.right() + is SimpleResult.Failure -> handleError(result).left() + } + }, + ifLeft = { it.left() }, + ) + } + + private fun handleError(result: SimpleResult.Failure): SendTransactionError { + if (ResultChecker.isNetworkError(result)) return SendTransactionError.NetworkError(result.error.message) + val error = result.error as? BlockchainSdkError ?: return SendTransactionError.UnknownError() + when (error) { + is BlockchainSdkError.WrappedTangemError -> { + val errorByCode = mapErrorByCode(error) + if (errorByCode != null) { + return errorByCode + } + val tangemSdkError = error.tangemError as? TangemSdkError ?: return SendTransactionError.UnknownError() + if (tangemSdkError is TangemSdkError.UserCancelled) return SendTransactionError.UserCancelledError + return SendTransactionError.TangemSdkError(tangemSdkError.code, tangemSdkError.cause) + } + else -> { + return SendTransactionError.TangemSdkError(error.code, error.cause) + } + } + } + + private fun mapErrorByCode(error: BlockchainSdkError.WrappedTangemError): SendTransactionError? { + return when (error.code) { + USER_CANCELLED_ERROR_CODE -> { + return SendTransactionError.UserCancelledError + } + else -> { + null + } + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt index 4c894e93d2..84303de548 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt @@ -2,12 +2,13 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either import arrow.core.raise.either -import com.tangem.common.doOnFailure -import com.tangem.common.doOnSuccess +import arrow.core.right +import com.tangem.common.CompletionResult import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.domain.wallets.legacy.ensureUserWalletListManagerNotNull import com.tangem.domain.wallets.models.SelectWalletError +import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId /** @@ -22,16 +23,20 @@ class SelectWalletUseCase( private val reduxStateHolder: ReduxStateHolder, ) { - suspend operator fun invoke(userWalletId: UserWalletId): Either { + suspend operator fun invoke(userWalletId: UserWalletId): Either { return either { val userWalletsListManager = ensureUserWalletListManagerNotNull( walletsStateHolder = walletsStateHolder, raise = { SelectWalletError.DataError }, ) - userWalletsListManager.select(userWalletId) - .doOnFailure { raise(SelectWalletError.UnableToSelectUserWallet) } - .doOnSuccess { reduxStateHolder.onUserWalletSelected(it) } + return when (val result = userWalletsListManager.select(userWalletId)) { + is CompletionResult.Failure -> raise(SelectWalletError.UnableToSelectUserWallet) + is CompletionResult.Success -> { + reduxStateHolder.onUserWalletSelected(result.data) + result.data.right() + } + } } } } \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 76bd921f71..fab77971a3 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -47,6 +47,9 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.utils) + /** Project - Data */ + implementation(projects.data.tokens) + /** Domain modules */ implementation(projects.common) implementation(projects.domain.card) diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/ChooseWalletState.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/ChooseWalletState.kt new file mode 100644 index 0000000000..44a0529872 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/ChooseWalletState.kt @@ -0,0 +1,31 @@ +package com.tangem.managetokens.presentation.common.state + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.managetokens.impl.R +import kotlinx.collections.immutable.ImmutableList + +internal sealed class ChooseWalletState { + data class Choose( + val wallets: ImmutableList, + val selectedWallet: WalletState?, + val onChooseWalletClick: () -> Unit, + val onCloseChoosingWalletClick: () -> Unit, + ) : ChooseWalletState() + + object NoSelection : ChooseWalletState() + + class Warning(val type: ChooseWalletWarning) : ChooseWalletState() { + val message: TextReference + get() = when (type) { + ChooseWalletWarning.SINGLE_CURRENCY -> + TextReference.Res(R.string.manage_tokens_wallet_support_only_one_network_title) + ChooseWalletWarning.WALLET_INCOMPATIBLE -> + TextReference.Res(R.string.manage_tokens_wallet_does_not_supported_blockchain) + } + } +} + +enum class ChooseWalletWarning { + SINGLE_CURRENCY, + WALLET_INCOMPATIBLE, +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/NetworkItemState.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/NetworkItemState.kt new file mode 100644 index 0000000000..a759836afc --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/NetworkItemState.kt @@ -0,0 +1,90 @@ +package com.tangem.managetokens.presentation.common.state + +import androidx.compose.runtime.MutableState +import com.tangem.blockchain.common.Blockchain +import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.extensions.getGreyedOutIconRes +import com.tangem.managetokens.presentation.managetokens.state.TokenItemState + +/** + * Network item state + * + * @property name network name + * @property protocolName network protocol name + * @property id network id + * @property blockchain blockchain + * @property iconRes network icon id from resources + */ +internal sealed interface NetworkItemState { + + val name: String + val protocolName: String + val id: String + val blockchain: Blockchain + + val iconRes: Int + get() = when (this) { + is Selectable -> this.iconResId + is Toggleable -> this.iconResId.value + } + + /** + * Network item state that can be added and deleted + * + * @property name network name + * @property protocolName network protocol name + * @property id network id + * @property blockchain blockchain + * @property iconResId network icon id from resources + * @property isMainNetwork flag that determines if the network is the main network for the token + * @property isAdded flag that determines if the user has saved the network + * @property address contract address + * @property decimals decimal count + * @property onToggleClick lambda be invoked when switch is been toggled + */ + @Suppress("LongParameterList") + class Toggleable( + override val name: String, + override val protocolName: String, + override val id: String, + override val blockchain: Blockchain, + val iconResId: MutableState, + val isMainNetwork: Boolean, + val isAdded: MutableState, + val address: String?, + val decimals: Int?, + val onToggleClick: (TokenItemState.Loaded, Toggleable) -> Unit, + ) : NetworkItemState { + + /** + * Change toggle state [isAdded]. + * + * It is a hack that helps us to change element of flow + */ + fun changeToggleState() { + val reverseState = !isAdded.value + isAdded.value = reverseState + iconResId.value = if (reverseState) getActiveIconRes(blockchain.id) else getGreyedOutIconRes(blockchain.id) + } + } + + /** + * Network item state that can be selected + * + * @property name network name + * @property protocolName network protocol name + * @property iconResId network icon id from resources + * @property id network id + * @property blockchain blockchain + * @property onNetworkClick lambda be invoked when network item is been clicked + * + */ + class Selectable( + override val name: String, + override val protocolName: String, + val iconResId: Int, + override val id: String, + override val blockchain: Blockchain, + val onNetworkClick: (NetworkItemState) -> Unit, + ) : NetworkItemState +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/WalletState.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/WalletState.kt new file mode 100644 index 0000000000..6af48736a4 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/WalletState.kt @@ -0,0 +1,8 @@ +package com.tangem.managetokens.presentation.common.state + +internal data class WalletState( + val walletId: String, + val artworkUrl: String?, + val walletName: String, + val onSelected: (String) -> Unit, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/previewdata/ChooseWalletStatePreviewData.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/previewdata/ChooseWalletStatePreviewData.kt new file mode 100644 index 0000000000..f231654b96 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/state/previewdata/ChooseWalletStatePreviewData.kt @@ -0,0 +1,27 @@ +package com.tangem.managetokens.presentation.common.state.previewdata + +import com.tangem.managetokens.presentation.common.state.ChooseWalletState +import com.tangem.managetokens.presentation.common.state.WalletState +import kotlinx.collections.immutable.persistentListOf + +internal object ChooseWalletStatePreviewData { + + val state: ChooseWalletState.Choose + get() = ChooseWalletState.Choose( + wallets = persistentListOf( + walletState, + walletState.copy(walletId = "2"), + ), + selectedWallet = walletState, + onChooseWalletClick = {}, + onCloseChoosingWalletClick = {}, + ) + + private val walletState: WalletState + get() = WalletState( + walletName = "My wallet", + walletId = "1", + artworkUrl = "", + onSelected = {}, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/ChooseWalletBottomSheet.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/ChooseWalletBottomSheet.kt new file mode 100644 index 0000000000..610f54edc4 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/ChooseWalletBottomSheet.kt @@ -0,0 +1,22 @@ +package com.tangem.managetokens.presentation.common.ui + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.managetokens.presentation.common.state.ChooseWalletState + +@Composable +internal fun ChooseWalletBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + contentColor = TangemTheme.colors.background.tertiary, + ) { + ChooseWalletScreen(state = it.chooseWalletState) + } +} + +internal class ChooseWalletBottomSheetConfig( + val chooseWalletState: ChooseWalletState.Choose, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/ChooseWalletScreen.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/ChooseWalletScreen.kt new file mode 100644 index 0000000000..d0dab333d8 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/ChooseWalletScreen.kt @@ -0,0 +1,151 @@ +package com.tangem.managetokens.presentation.common.ui + +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.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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 coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.managetokens.impl.R +import com.tangem.managetokens.presentation.common.state.ChooseWalletState +import com.tangem.managetokens.presentation.common.state.WalletState +import com.tangem.managetokens.presentation.common.state.previewdata.ChooseWalletStatePreviewData + +@Composable +internal fun ChooseWalletScreen(state: ChooseWalletState.Choose, modifier: Modifier = Modifier) { + LazyColumn( + modifier = modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing16), + ) { + item { + Box( + modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size44), + ) { + IconButton( + onClick = state.onCloseChoosingWalletClick, + modifier = Modifier.align(Alignment.CenterStart), + ) { + Icon( + painterResource(id = R.drawable.ic_back_24), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + } + Text( + text = stringResource(id = R.string.manage_tokens_wallet_selector_title), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + ) + } + } + items( + count = state.wallets.count(), + key = { index -> state.wallets[index].walletId }, + ) { index -> + WalletItem( + wallet = state.wallets[index], + selectedWallet = state.selectedWallet, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.wallets.lastIndex, + addDefaultPadding = false, + ), + ) + } + item { + SpacerH(height = TangemTheme.dimens.spacing16) + } + } +} + +@Composable +private fun WalletItem(wallet: WalletState, selectedWallet: WalletState?, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clickable { wallet.onSelected(wallet.walletId) } + .background(TangemTheme.colors.background.action) + .defaultMinSize(minHeight = TangemTheme.dimens.size72) + .padding(horizontal = TangemTheme.dimens.spacing16), + verticalAlignment = Alignment.CenterVertically, + ) { + SubcomposeAsyncImage( + modifier = Modifier.size(height = TangemTheme.dimens.size30, width = TangemTheme.dimens.size50), + + model = ImageRequest.Builder(context = LocalContext.current) + .data(wallet.artworkUrl) + .crossfade(enable = true) + .build(), + loading = { + Image( + painter = painterResource(R.drawable.card_placeholder_primary), + contentDescription = null, + ) + }, + error = { + Image( + painter = painterResource(R.drawable.card_placeholder_primary), + contentDescription = null, + ) + }, + contentDescription = null, + ) + SpacerW12() + Text( + text = wallet.walletName, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + SpacerWMax() + if (selectedWallet == wallet) { + Icon( + painter = painterResource(id = R.drawable.ic_check_24), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + } +} + +@Preview +@Composable +private fun Preview_ChooseWalletScreen_Light() { + TangemTheme(isDark = false) { + ChooseWalletScreen( + state = ChooseWalletStatePreviewData.state, + ) + } +} + +@Preview +@Composable +private fun Preview_ChooseWalletScreen_Dark() { + TangemTheme(isDark = false) { + ChooseWalletScreen( + state = ChooseWalletStatePreviewData.state, + ) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/components/NetworkItem.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/components/NetworkItem.kt new file mode 100644 index 0000000000..18de0a171a --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/components/NetworkItem.kt @@ -0,0 +1,157 @@ +package com.tangem.managetokens.presentation.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.blockchain.common.Blockchain +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.TangemSwitch +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.managetokens.impl.R +import com.tangem.managetokens.presentation.common.state.NetworkItemState +import com.tangem.managetokens.presentation.managetokens.state.TokenItemState +import com.tangem.managetokens.presentation.managetokens.state.previewdata.TokenItemStatePreviewData + +@Composable +internal fun NetworkItem(state: NetworkItemState, tokenState: TokenItemState.Loaded?, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background(TangemTheme.colors.background.action) + .defaultMinSize(minHeight = TangemTheme.dimens.size68) + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + NetworkIcon(model = state) + SpacerW(width = TangemTheme.dimens.spacing12) + Text( + text = state.name, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle2, + ) + SpacerW(width = TangemTheme.dimens.spacing6) + Text( + text = state.protocolName, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + modifier = Modifier + .weight(1f), + ) + if (state is NetworkItemState.Toggleable) { + TangemSwitch( + onCheckedChange = { + state.onToggleClick(tokenState!!, state) + }, + checked = state.isAdded.value, + ) + } + } +} + +@Composable +internal fun NetworkIcon(model: NetworkItemState, modifier: Modifier = Modifier) { + Box(modifier = modifier.size(size = TangemTheme.dimens.size36)) { + val isAdded = when (model) { + is NetworkItemState.Selectable -> true + is NetworkItemState.Toggleable -> model.isAdded.value + } + + if (!isAdded) { + Box( + modifier = Modifier + .size(TangemTheme.dimens.size36) + .clip(CircleShape) + .background(TangemTheme.colors.control.unchecked), + ) + } + Icon( + painter = painterResource(id = model.iconRes), + contentDescription = null, + modifier = Modifier.size(size = TangemTheme.dimens.size36), + tint = if (isAdded) Color.Unspecified else TangemTheme.colors.text.tertiary, + ) + + if (model is NetworkItemState.Toggleable && model.isMainNetwork) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .size(TangemTheme.dimens.size10) + .clip(CircleShape) + .background(TangemTheme.colors.stroke.transparency), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(TangemTheme.dimens.size8) + .clip(CircleShape) + .background(TangemTheme.colors.icon.accent), + ) + } + } + } +} + +@Preview +@Composable +private fun Preview_NetworkItem_Light(@PreviewParameter(NetworkItemStateProvider::class) state: NetworkItemState) { + TangemTheme(isDark = false) { + NetworkItem(state, tokenState = TokenItemStatePreviewData.loadedPriceDown as TokenItemState.Loaded) + } +} + +@Preview +@Composable +private fun Preview_NetworkItem_Dark(@PreviewParameter(NetworkItemStateProvider::class) state: NetworkItemState) { + TangemTheme(isDark = true) { + NetworkItem(state, tokenState = TokenItemStatePreviewData.loadedPriceDown as TokenItemState.Loaded) + } +} + +private class NetworkItemStateProvider : CollectionPreviewParameterProvider( + collection = listOf( + NetworkItemState.Toggleable( + name = "Ethereum", + protocolName = "ETH", + iconResId = mutableStateOf(R.drawable.img_polygon_22), + isMainNetwork = true, + isAdded = mutableStateOf(true), + id = "", + address = "", + onToggleClick = { _, _ -> }, + blockchain = Blockchain.Ethereum, + decimals = 0, + ), + NetworkItemState.Toggleable( + name = "BNB SMART CHAIN", + protocolName = "BEP20", + iconResId = mutableStateOf(R.drawable.ic_bsc_16), + isMainNetwork = false, + isAdded = mutableStateOf(false), + id = "", + address = "", + onToggleClick = { _, _ -> }, + blockchain = Blockchain.BSC, + decimals = 0, + ), + NetworkItemState.Selectable( + name = "Ethereum", + protocolName = "ETH", + iconResId = R.drawable.img_polygon_22, + id = "", + onNetworkClick = { }, + blockchain = Blockchain.Ethereum, + ), + ), +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/components/SimpleSelectionBlock.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/components/SimpleSelectionBlock.kt new file mode 100644 index 0000000000..c136cb1e28 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/common/ui/components/SimpleSelectionBlock.kt @@ -0,0 +1,70 @@ +package com.tangem.managetokens.presentation.common.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.components.SpacerH +import com.tangem.core.ui.res.TangemTheme + +@Composable +fun SimpleSelectionBlock( + title: String, + subtitle: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + roundedCorners: Boolean = true, +) { + Column( + modifier = modifier + .then( + if (roundedCorners) { + Modifier.clip(shape = RoundedCornerShape(TangemTheme.dimens.radius16)) + } else { + Modifier + }, + ) + .background(color = TangemTheme.colors.background.action) + .clickable { onClick() } + .padding( + horizontal = TangemTheme.dimens.spacing20, + vertical = TangemTheme.dimens.spacing16, + ) + .fillMaxWidth(), + ) { + Text( + text = title, + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + ) + SpacerH(height = TangemTheme.dimens.spacing4) + Text( + text = subtitle, + color = TangemTheme.colors.text.secondary, + style = TangemTheme.typography.body2, + ) + } +} + +@Preview +@Composable +private fun Preview_SimpleSelectionBlock_Light() { + TangemTheme(isDark = false) { + SimpleSelectionBlock(title = "Wallet", subtitle = "Family Wallet", onClick = { }) + } +} + +@Preview +@Composable +private fun Preview_SimpleSelectionBlock_Dark() { + TangemTheme(isDark = true) { + SimpleSelectionBlock(title = "Wallet", subtitle = "Family Wallet", onClick = { }) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/ChooseNetworkState.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/ChooseNetworkState.kt new file mode 100644 index 0000000000..df2a7f015f --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/ChooseNetworkState.kt @@ -0,0 +1,11 @@ +package com.tangem.managetokens.presentation.managetokens.state + +import com.tangem.managetokens.presentation.common.state.NetworkItemState +import kotlinx.collections.immutable.ImmutableList + +internal data class ChooseNetworkState( + val nativeNetworks: ImmutableList, + val nonNativeNetworks: ImmutableList, + val onNonNativeNetworkHintClick: () -> Unit, + val onCloseChooseNetworkScreen: () -> Unit, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/DerivationNotificationState.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/DerivationNotificationState.kt index 8856ab58c5..0cfdfa16d1 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/DerivationNotificationState.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/DerivationNotificationState.kt @@ -8,7 +8,8 @@ import com.tangem.features.managetokens.impl.R data class DerivationNotificationState( val totalNeeded: Int, - val missingAddressesCount: Int, + val totalWallets: Int, + val walletsToDerive: Int, val onGenerateClick: () -> Unit, ) { val config = NotificationConfig( @@ -25,8 +26,8 @@ data class DerivationNotificationState( onClick = onGenerateClick, additionalText = pluralReference( id = R.plurals.manage_tokens_number_of_wallets_android, - count = totalNeeded, - formatArgs = wrappedList(missingAddressesCount, totalNeeded), + count = totalWallets, + formatArgs = wrappedList(walletsToDerive, totalWallets), ), ), ) diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/ManageTokensState.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/ManageTokensState.kt new file mode 100644 index 0000000000..1ee0ecebce --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/ManageTokensState.kt @@ -0,0 +1,24 @@ +package com.tangem.managetokens.presentation.managetokens.state + +import androidx.paging.PagingData +import com.tangem.core.ui.event.StateEvent +import com.tangem.managetokens.presentation.common.state.ChooseWalletState +import com.tangem.managetokens.presentation.common.state.Event +import kotlinx.coroutines.flow.Flow + +internal data class ManageTokensState( + val searchBarState: SearchBarState, + val tokens: Flow>, + val isLoading: Boolean, + val addCustomTokenButton: AddCustomTokenButton, + val chooseWalletState: ChooseWalletState, + val derivationNotification: DerivationNotificationState? = null, + val selectedToken: TokenItemState.Loaded? = null, + val showChooseWalletScreen: Boolean = false, + val event: StateEvent, +) + +data class AddCustomTokenButton( + val isVisible: Boolean, + val onClick: () -> Unit, +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/TokenItemState.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/TokenItemState.kt index ae386cc7c1..7262bbe337 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/TokenItemState.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/TokenItemState.kt @@ -1,5 +1,7 @@ package com.tangem.managetokens.presentation.managetokens.state +import androidx.compose.runtime.MutableState + internal sealed class TokenItemState { abstract val id: String @@ -9,11 +11,13 @@ internal sealed class TokenItemState { data class Loaded( override val id: String, val name: String, - val currencyId: String, + val currencySymbol: String, + val tokenId: String, val tokenIcon: TokenIconState, val quotes: QuotesState, val rate: String?, - val availableAction: TokenButtonType, - val onButtonClick: (String) -> Unit, + val availableAction: MutableState, + val chooseNetworkState: ChooseNetworkState, + val onButtonClick: (Loaded) -> Unit, ) : TokenItemState() } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/ChooseNetworkStatePreviewData.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/ChooseNetworkStatePreviewData.kt new file mode 100644 index 0000000000..12be7db588 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/ChooseNetworkStatePreviewData.kt @@ -0,0 +1,60 @@ +package com.tangem.managetokens.presentation.managetokens.state.previewdata + +import androidx.compose.runtime.mutableStateOf +import com.tangem.blockchain.common.Blockchain +import com.tangem.features.managetokens.impl.R +import com.tangem.managetokens.presentation.common.state.NetworkItemState +import com.tangem.managetokens.presentation.managetokens.state.ChooseNetworkState +import kotlinx.collections.immutable.toImmutableList + +internal object ChooseNetworkStatePreviewData { + + val state = ChooseNetworkState( + nativeNetworks = nativeNetworks.toImmutableList(), + nonNativeNetworks = nonNativeNetworks.toImmutableList(), + onNonNativeNetworkHintClick = {}, + onCloseChooseNetworkScreen = {}, + ) +} + +internal val nativeNetworks = listOf( + NetworkItemState.Toggleable( + name = "Ethereum", + protocolName = "ETH", + iconResId = mutableStateOf(R.drawable.img_polygon_22), + isMainNetwork = true, + isAdded = mutableStateOf(true), + id = "", + onToggleClick = { _, _ -> }, + blockchain = Blockchain.Ethereum, + address = "", + decimals = 0, + ), +) + +internal val nonNativeNetworks = listOf( + NetworkItemState.Toggleable( + name = "Ethereum", + protocolName = "ETH", + iconResId = mutableStateOf(R.drawable.img_kusama_22), + isMainNetwork = false, + isAdded = mutableStateOf(true), + id = "", + onToggleClick = { _, _ -> }, + blockchain = Blockchain.Ethereum, + address = "", + decimals = 0, + ), + NetworkItemState.Toggleable( + name = "BNB SMART CHAIN", + protocolName = "BEP20", + iconResId = mutableStateOf(R.drawable.ic_bsc_16), + isMainNetwork = false, + isAdded = mutableStateOf(false), + id = "", + onToggleClick = { _, _ -> }, + blockchain = Blockchain.BSC, + address = "", + decimals = 0, + ), +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/DerivationNotificationStatePreviewData.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/DerivationNotificationStatePreviewData.kt index 207ea75cb8..b5d88b814a 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/DerivationNotificationStatePreviewData.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/DerivationNotificationStatePreviewData.kt @@ -4,8 +4,9 @@ import com.tangem.managetokens.presentation.managetokens.state.DerivationNotific object DerivationNotificationStatePreviewData { val state = DerivationNotificationState( - totalNeeded = 3, - missingAddressesCount = 2, + totalNeeded = 5, + totalWallets = 3, + walletsToDerive = 2, onGenerateClick = {}, ) } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/ManageTokensStatePreviewData.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/ManageTokensStatePreviewData.kt new file mode 100644 index 0000000000..6ea8316ba5 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/ManageTokensStatePreviewData.kt @@ -0,0 +1,40 @@ +package com.tangem.managetokens.presentation.managetokens.state.previewdata + +import androidx.paging.PagingData +import com.tangem.core.ui.event.consumedEvent +import com.tangem.managetokens.presentation.common.state.previewdata.ChooseWalletStatePreviewData +import com.tangem.managetokens.presentation.managetokens.state.AddCustomTokenButton +import com.tangem.managetokens.presentation.managetokens.state.ManageTokensState +import com.tangem.managetokens.presentation.managetokens.state.SearchBarState +import com.tangem.managetokens.presentation.managetokens.state.TokenItemState +import kotlinx.coroutines.flow.flowOf + +internal object ManageTokensStatePreviewData { + val loadedState: ManageTokensState + get() = ManageTokensState( + searchBarState = searchState, + tokens = flowOf(PagingData.from(tokens)), + isLoading = false, + addCustomTokenButton = AddCustomTokenButton(true, {}), + derivationNotification = DerivationNotificationStatePreviewData.state, + event = consumedEvent(), + chooseWalletState = ChooseWalletStatePreviewData.state, + ) + + val loadingState: ManageTokensState + get() = loadedState.copy(isLoading = true) + + private val tokens: List + get() = listOf( + TokenItemStatePreviewData.loadedPriceDown, + TokenItemStatePreviewData.loadedPriceUp, + ) + + private val searchState: SearchBarState + get() = SearchBarState( + query = "", + onQueryChange = {}, + active = false, + onActiveChange = {}, + ) +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/TokenItemStatePreviewData.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/TokenItemStatePreviewData.kt index bdfedacd3e..01ed9bdda9 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/TokenItemStatePreviewData.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/previewdata/TokenItemStatePreviewData.kt @@ -1,5 +1,6 @@ package com.tangem.managetokens.presentation.managetokens.state.previewdata +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.Color import com.tangem.managetokens.presentation.managetokens.state.* import kotlinx.collections.immutable.persistentListOf @@ -13,7 +14,8 @@ internal object TokenItemStatePreviewData { get() = TokenItemState.Loaded( id = "BTC", name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", - currencyId = "BTC", + tokenId = "BTC", + currencySymbol = "BTC", tokenIcon = tokenIconState, quotes = QuotesState.Content( priceChange = "0.43%", @@ -21,15 +23,17 @@ internal object TokenItemStatePreviewData { chartData = persistentListOf(10f, 2f, 5f, 3f, 4f, 8f, 9f, 7f, 4f), ), rate = "31 285.72$", - availableAction = TokenButtonType.ADD, + availableAction = mutableStateOf(TokenButtonType.ADD), onButtonClick = {}, + chooseNetworkState = ChooseNetworkStatePreviewData.state, ) val loadedPriceUp: TokenItemState get() = TokenItemState.Loaded( id = "BTC", name = "Bitcoin", - currencyId = "BTC", + tokenId = "BTC", + currencySymbol = "BTC", tokenIcon = tokenIconState, quotes = QuotesState.Content( priceChange = "0.43%", @@ -37,8 +41,9 @@ internal object TokenItemStatePreviewData { chartData = persistentListOf(1f, 3f, 4f, 8f, 12f, 10f, 8f, 3f, 5f, 7f), ), rate = "31 285.72$", - availableAction = TokenButtonType.NOT_AVAILABLE, + availableAction = mutableStateOf(TokenButtonType.NOT_AVAILABLE), onButtonClick = {}, + chooseNetworkState = ChooseNetworkStatePreviewData.state, ) private val tokenIconState: TokenIconState diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ChooseNetworkBottomSheet.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ChooseNetworkBottomSheet.kt new file mode 100644 index 0000000000..cd1809a96b --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ChooseNetworkBottomSheet.kt @@ -0,0 +1,24 @@ +package com.tangem.managetokens.presentation.managetokens.ui + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.managetokens.presentation.common.state.ChooseWalletState +import com.tangem.managetokens.presentation.managetokens.state.TokenItemState + +@Composable +internal fun ChooseNetworkBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + contentColor = TangemTheme.colors.background.tertiary, + ) { + ChooseNetworkScreen(state = it.selectedToken, walletState = it.chooseWalletState) + } +} + +internal class ChooseNetworkBottomSheetConfig( + val selectedToken: TokenItemState.Loaded, + val chooseWalletState: ChooseWalletState, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ChooseNetworkScreen.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ChooseNetworkScreen.kt new file mode 100644 index 0000000000..ebc600073f --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ChooseNetworkScreen.kt @@ -0,0 +1,205 @@ +package com.tangem.managetokens.presentation.managetokens.ui + +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.material.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.SpacerH +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.WarningCardTitleOnly +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.managetokens.impl.R +import com.tangem.managetokens.presentation.common.state.ChooseWalletState +import com.tangem.managetokens.presentation.common.state.previewdata.ChooseWalletStatePreviewData +import com.tangem.managetokens.presentation.common.ui.components.NetworkItem +import com.tangem.managetokens.presentation.common.ui.components.SimpleSelectionBlock +import com.tangem.managetokens.presentation.managetokens.state.ChooseNetworkState +import com.tangem.managetokens.presentation.managetokens.state.TokenItemState +import com.tangem.managetokens.presentation.managetokens.state.previewdata.TokenItemStatePreviewData + +@Composable +internal fun ChooseNetworkScreen( + state: TokenItemState.Loaded, + walletState: ChooseWalletState, + modifier: Modifier = Modifier, +) { + val networkState = state.chooseNetworkState + LazyColumn( + modifier = modifier + .background(TangemTheme.colors.background.tertiary) + .padding(TangemTheme.dimens.spacing16), + ) { + item { + Text( + text = stringResource(id = R.string.manage_tokens_network_selector_title), + color = TangemTheme.colors.text.primary1, + style = TangemTheme.typography.subtitle1, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth(), + ) + } + + when (walletState) { + is ChooseWalletState.Choose -> { + item { + SpacerH(height = TangemTheme.dimens.spacing10) + } + item { + SimpleSelectionBlock( + title = stringResource(id = R.string.manage_tokens_network_selector_wallet), + subtitle = walletState.selectedWallet?.walletName ?: "", + onClick = walletState.onChooseWalletClick, + ) + } + } + ChooseWalletState.NoSelection -> Unit + is ChooseWalletState.Warning -> { + item { + SpacerH(height = TangemTheme.dimens.spacing10) + } + item { + WarningCardTitleOnly( + title = stringResource(id = R.string.manage_tokens_wallet_support_only_one_network_title), + ) + } + } + } + + item { + SpacerH(height = TangemTheme.dimens.spacing16) + } + + item { + if (networkState.nativeNetworks.isNotEmpty()) { + NativeNetworks(networkState = networkState, tokenState = state) + } + } + + if (networkState.nonNativeNetworks.isNotEmpty()) { + item { + NonNativeNetworksHeader(networkState.onNonNativeNetworkHintClick) + } + item { + SpacerH(height = TangemTheme.dimens.spacing8) + } + item { + this@LazyColumn.NonNativeNetworks(networkState = networkState, tokenState = state) + } + } + } +} + +@Composable +private fun NativeNetworks(networkState: ChooseNetworkState, tokenState: TokenItemState.Loaded) { + Column { + Text( + text = stringResource(id = R.string.manage_tokens_network_selector_native_title), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption1, + ) + SpacerH(height = TangemTheme.dimens.spacing2) + Text( + text = stringResource(id = R.string.manage_tokens_network_selector_native_subtitle), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + ) + SpacerH(height = TangemTheme.dimens.spacing8) + + networkState.nativeNetworks.forEachIndexed { index, network -> + NetworkItem( + state = network, + tokenState = tokenState, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = networkState.nativeNetworks.lastIndex, + addDefaultPadding = false, + ), + ) + } + SpacerH(height = TangemTheme.dimens.spacing16) + } +} + +@Composable +private fun LazyListScope.NonNativeNetworks(networkState: ChooseNetworkState, tokenState: TokenItemState.Loaded) { + items( + count = networkState.nonNativeNetworks.count(), + key = { index -> networkState.nonNativeNetworks[index].id }, + ) { index -> + NetworkItem( + state = networkState.nonNativeNetworks[index], + tokenState = tokenState, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = networkState.nonNativeNetworks.lastIndex, + addDefaultPadding = false, + ), + ) + } + item { + SpacerH(height = TangemTheme.dimens.spacing16) + } +} + +@Composable +private fun NonNativeNetworksHeader(onNonNativeNetworkHintClick: () -> Unit) { + Column { + Row { + Text( + text = stringResource(id = R.string.manage_tokens_network_selector_non_native_title), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption1, + ) + SpacerW(width = TangemTheme.dimens.spacing2) + Icon( + painter = painterResource(id = R.drawable.ic_information_24), + tint = TangemTheme.colors.icon.inactive, + contentDescription = null, + modifier = Modifier + .size(TangemTheme.dimens.size16) + .clickable { onNonNativeNetworkHintClick() }, + ) + } + SpacerH(height = TangemTheme.dimens.spacing2) + Text( + text = stringResource(id = R.string.manage_tokens_network_selector_non_native_subtitle), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.caption2, + ) + } +} + +@Preview +@Composable +private fun Preview_ChooseNetworkScreen_Light() { + TangemTheme(isDark = false) { + ChooseNetworkScreen( + state = TokenItemStatePreviewData.loadedPriceDown as TokenItemState.Loaded, + walletState = ChooseWalletStatePreviewData.state, + ) + } +} + +@Preview +@Composable +private fun Preview_ChooseNetworkScreen_Dark() { + TangemTheme(isDark = true) { + ChooseNetworkScreen( + state = TokenItemStatePreviewData.loadedPriceDown as TokenItemState.Loaded, + walletState = ChooseWalletStatePreviewData.state, + ) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ManageTokensScreen.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ManageTokensScreen.kt new file mode 100644 index 0000000000..1bbf0c61a9 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/ManageTokensScreen.kt @@ -0,0 +1,144 @@ +package com.tangem.managetokens.presentation.managetokens.ui + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Surface +import androidx.compose.runtime.* +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.datasource.CollectionPreviewParameterProvider +import androidx.paging.compose.collectAsLazyPagingItems +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.res.TangemTheme +import com.tangem.managetokens.presentation.common.state.AlertState +import com.tangem.managetokens.presentation.common.state.ChooseWalletState +import com.tangem.managetokens.presentation.common.ui.ChooseWalletBottomSheet +import com.tangem.managetokens.presentation.common.ui.ChooseWalletBottomSheetConfig +import com.tangem.managetokens.presentation.common.ui.EventEffect +import com.tangem.managetokens.presentation.common.ui.components.Alert +import com.tangem.managetokens.presentation.managetokens.state.ManageTokensState +import com.tangem.managetokens.presentation.managetokens.state.TokenItemState +import com.tangem.managetokens.presentation.managetokens.state.previewdata.ManageTokensStatePreviewData +import com.tangem.managetokens.presentation.managetokens.ui.components.DerivationNotification +import com.tangem.managetokens.presentation.managetokens.ui.components.TokensList +import com.tangem.managetokens.presentation.managetokens.ui.components.TokensSearchBar + +@Composable +internal fun ManageTokensScreen(state: ManageTokensState) { + var alertState by remember { mutableStateOf(value = null) } + + EventEffect( + event = state.event, + onAlertStateSet = { alertState = it }, + ) + alertState?.let { + Alert(state = it, onDismiss = { alertState = null }) + } + + Content(state) +} + +@Composable +private fun Content(state: ManageTokensState) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = TangemTheme.colors.background.primary) + .padding(top = TangemTheme.dimens.spacing32), + ) { + Column { + val listState = rememberLazyListState() + val raiseSearchBar by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } + + val elevation by animateDpAsState( + targetValue = if (raiseSearchBar) { + TangemTheme.dimens.elevation8 + } else { + TangemTheme.dimens.elevation0 + }, + label = "top_bar_elevation", + ) + Surface( + elevation = elevation, + modifier = Modifier, + ) { + TokensSearchBar( + state = state.searchBarState, + modifier = Modifier + .background(color = TangemTheme.colors.background.primary) + .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing20), + ) + } + val tokens = state.tokens.collectAsLazyPagingItems() + TokensList(tokens = tokens, addCustomTokenButton = state.addCustomTokenButton) + } + state.derivationNotification?.let { + DerivationNotification( + config = it.config, + modifier = Modifier + .align(Alignment.BottomCenter), + ) + } + state.selectedToken?.let { selectedToken -> + ManageTokensBottomSheet(selectedToken = selectedToken, state = state) + } + } +} + +@Composable +private fun ManageTokensBottomSheet(selectedToken: TokenItemState.Loaded, state: ManageTokensState) { + if (state.showChooseWalletScreen && state.chooseWalletState is ChooseWalletState.Choose) { + val config = TangemBottomSheetConfig( + isShow = true, + content = ChooseWalletBottomSheetConfig(state.chooseWalletState), + onDismissRequest = state.chooseWalletState.onCloseChoosingWalletClick, + ) + ChooseWalletBottomSheet(config) + } else { + val config = TangemBottomSheetConfig( + isShow = true, + content = ChooseNetworkBottomSheetConfig( + selectedToken = selectedToken, + chooseWalletState = state.chooseWalletState, + ), + onDismissRequest = selectedToken.chooseNetworkState.onCloseChooseNetworkScreen, + ) + ChooseNetworkBottomSheet(config) + } +} + +@Preview +@Composable +private fun Preview_ManageTokensScreen_LightTheme( + @PreviewParameter(ManageTokensConfigProvider::class) + state: ManageTokensState, +) { + TangemTheme(isDark = false) { + ManageTokensScreen(state) + } +} + +@Preview +@Composable +private fun Preview_ManageTokensScreen_DarkTheme( + @PreviewParameter(ManageTokensConfigProvider::class) + state: ManageTokensState, +) { + TangemTheme(isDark = true) { + ManageTokensScreen(state) + } +} + +private class ManageTokensConfigProvider : CollectionPreviewParameterProvider( + collection = listOf( + ManageTokensStatePreviewData.loadingState, + ManageTokensStatePreviewData.loadedState, + ), +) \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/AddCustomTokenButton.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/AddCustomTokenButton.kt new file mode 100644 index 0000000000..195bb4d60c --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/AddCustomTokenButton.kt @@ -0,0 +1,65 @@ +package com.tangem.managetokens.presentation.managetokens.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.managetokens.impl.R + +@Composable +internal fun AddCustomTokenButton(onButtonClick: () -> Unit, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size68) + .fillMaxWidth() + .clickable { onButtonClick() } + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + Box( + modifier = Modifier + .size(TangemTheme.dimens.size36) + .background(color = TangemTheme.colors.button.secondary, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_plus_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + ) + } + SpacerW(width = TangemTheme.dimens.spacing12) + Text( + text = stringResource(id = R.string.add_custom_token_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + } +} + +@Preview +@Composable +private fun AddCustomTokenButton_Preview_Light() { + TangemTheme(isDark = false) { + AddCustomTokenButton(onButtonClick = { }) + } +} + +@Preview +@Composable +private fun AddCustomTokenButton_Preview_Dark() { + TangemTheme(isDark = true) { + AddCustomTokenButton(onButtonClick = { }) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokenRowItem.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokenRowItem.kt index 139e4f9652..12a403e3e4 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokenRowItem.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokenRowItem.kt @@ -54,7 +54,7 @@ private fun LoadedTokenItem(state: TokenItemState.Loaded, modifier: Modifier = M modifier = Modifier .weight(weight = 1f), ) { - TokenName(name = state.name, currencyId = state.currencyId) + TokenName(name = state.name, currencyId = state.currencySymbol) TokenPriceData(price = state.rate, quotesState = state.quotes) } SpacerW24() @@ -67,8 +67,8 @@ private fun LoadedTokenItem(state: TokenItemState.Loaded, modifier: Modifier = M } TokenButton( - type = state.availableAction, - onClick = { state.onButtonClick(state.currencyId) }, + type = state.availableAction.value, + onClick = { state.onButtonClick(state) }, ) } } diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokensList.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokensList.kt new file mode 100644 index 0000000000..fe6c1c2bf7 --- /dev/null +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokensList.kt @@ -0,0 +1,59 @@ +package com.tangem.managetokens.presentation.managetokens.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.managetokens.impl.R +import com.tangem.managetokens.presentation.managetokens.state.AddCustomTokenButton +import com.tangem.managetokens.presentation.managetokens.state.TokenItemState + +private const val PLACEHOLDER_ITEMS_COUNT = 50 + +@Composable +internal fun TokensList(tokens: LazyPagingItems, addCustomTokenButton: AddCustomTokenButton) { + LazyColumn { + item { + Text( + text = stringResource(id = R.string.manage_tokens_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + + if (tokens.loadState.refresh is LoadState.Loading) { + items(PLACEHOLDER_ITEMS_COUNT) { + TokenRowItem(state = TokenItemState.Loading(it.toString())) + } + } else { + val tokensList = tokens.itemSnapshotList + if (tokensList.isEmpty()) { + item { + Text( + text = stringResource(id = R.string.manage_tokens_nothing_found), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + } + + items(items = tokensList.items, key = TokenItemState::id) { token -> + TokenRowItem(state = token) + } + + if (addCustomTokenButton.isVisible) { + item { + AddCustomTokenButton(onButtonClick = { addCustomTokenButton.onClick }) + } + } + } + } +} \ No newline at end of file diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index bb59a8e295..1af2bafaee 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -45,6 +45,7 @@ dependencies { implementation(projects.core.featuretoggles) implementation(projects.core.ui) implementation(projects.core.utils) + implementation(projects.core.navigation) /** Domain modules */ implementation(projects.domain.models) @@ -58,6 +59,8 @@ dependencies { implementation(projects.domain.txhistory) implementation(projects.domain.txhistory.models) implementation(projects.domain.transaction) + implementation(projects.domain.card) + implementation(projects.domain.demo) /** Feature modules */ implementation(projects.features.send.api) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/di/SendRouterModule.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/di/SendRouterModule.kt index 8bf19609d8..7f38cacd35 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/di/SendRouterModule.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/di/SendRouterModule.kt @@ -1,5 +1,6 @@ package com.tangem.features.send.impl.di +import com.tangem.core.navigation.ReduxNavController import com.tangem.features.send.api.navigation.SendRouter import com.tangem.features.send.impl.navigation.DefaultSendRouter import dagger.Module @@ -17,7 +18,7 @@ internal object SendRouterModule { @Provides @ActivityScoped - fun provideSendRouter(): SendRouter { - return DefaultSendRouter() + fun provideSendRouter(reduxNavController: ReduxNavController): SendRouter { + return DefaultSendRouter(reduxNavController) } } \ No newline at end of file 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 4db1a5a674..c72ca92ae7 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 @@ -1,9 +1,17 @@ package com.tangem.features.send.impl.navigation import androidx.fragment.app.Fragment -import com.tangem.features.send.api.navigation.SendRouter +import com.tangem.core.navigation.NavigationAction +import com.tangem.core.navigation.ReduxNavController import com.tangem.features.send.impl.presentation.SendFragment -internal class DefaultSendRouter : SendRouter { +internal class DefaultSendRouter( + private val reduxNavController: ReduxNavController, +) : InnerSendRouter { + override fun getEntryFragment(): Fragment = SendFragment.create() + + override fun openUrl(url: String) { + reduxNavController.navigate(NavigationAction.OpenUrl(url = url)) + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/InnerSendRouter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/InnerSendRouter.kt new file mode 100644 index 0000000000..b7cd7f934a --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/navigation/InnerSendRouter.kt @@ -0,0 +1,9 @@ +package com.tangem.features.send.impl.navigation + +import com.tangem.features.send.api.navigation.SendRouter + +interface InnerSendRouter : SendRouter { + + /** Open website by [url] */ + fun openUrl(url: String) +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt index 2790caab93..cd47d6c8fe 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt @@ -8,6 +8,8 @@ import com.tangem.core.ui.components.SystemBarsEffect import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeFragment import com.tangem.core.ui.theme.AppThemeModeHolder +import com.tangem.features.send.api.navigation.SendRouter +import com.tangem.features.send.impl.navigation.InnerSendRouter import com.tangem.features.send.impl.presentation.state.StateRouter import com.tangem.features.send.impl.presentation.ui.SendScreen import com.tangem.features.send.impl.presentation.viewmodel.SendViewModel @@ -24,12 +26,20 @@ internal class SendFragment : ComposeFragment() { @Inject override lateinit var appThemeModeHolder: AppThemeModeHolder + @Inject + lateinit var router: SendRouter + private val viewModel by viewModels() + private val innerSendRouter: InnerSendRouter + get() = requireNotNull(router as? InnerSendRouter) { + "innerSendRouter should be instance of InnerSendRouter" + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) viewModel.setRouter( + innerSendRouter, StateRouter( fragmentManager = WeakReference(parentFragmentManager), ), diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendNotification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendNotification.kt new file mode 100644 index 0000000000..720270d86d --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendNotification.kt @@ -0,0 +1,10 @@ +package com.tangem.features.send.impl.presentation.domain + +sealed class SendNotification { + + sealed class Info(val message: String) : SendNotification() + + sealed class Critical(val message: String) : SendNotification() + + sealed class Error(val message: String) : SendNotification() +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt index 38583d0676..bae35722ae 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendStateFactory.kt @@ -86,6 +86,7 @@ internal class SendStateFactory( amountState = amountStateConverter.convert(Unit), recipientState = recipientStateConverter.convert(Unit), feeState = feeStateConverter.convert(Unit), + sendState = SendStates.SendState(), ) //endregion @@ -122,15 +123,15 @@ internal class SendStateFactory( val recipientState = state.recipientState ?: return state val isValidMemo = validateMemo( - memo = value, + memo = recipientState.addressTextField.value.value, cryptoCurrency = cryptoCurrencyStatusProvider().currency, ) val isAddressInWallet = isNotAddressInWallet( + address = value, walletAddresses = walletAddressesProvider(), - address = recipientState.addressTextField.value.value, ) val isValidAddress = verifyAddress( - address = recipientState.addressTextField.value.value, + address = value, cryptoCurrency = cryptoCurrencyStatusProvider().currency, ) @@ -138,8 +139,7 @@ internal class SendStateFactory( it.copy( value = value, error = when { - !isValidAddress -> TextReference.Res(R.string.send_recipient_address_error) - !isAddressInWallet -> TextReference.Res(R.string.send_recipient_address_error) + !isValidAddress || !isAddressInWallet -> TextReference.Res(R.string.send_recipient_address_error) else -> null }, isError = !isValidAddress || !isAddressInWallet, @@ -169,11 +169,9 @@ internal class SendStateFactory( cryptoCurrency = cryptoCurrencyStatusProvider().currency, ) - // todo add memo validation error text recipientState.memoTextField?.update { it.copy( value = value, - error = TextReference.Res(R.string.send_memo_destination_tag_error), isError = !isValidMemo, ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt index 207e63064d..1d7474d6b4 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendUiState.kt @@ -23,6 +23,7 @@ internal data class SendUiState( val amountState: SendStates.AmountState? = null, val recipientState: SendStates.RecipientState? = null, val feeState: SendStates.FeeState? = null, + val sendState: SendStates.SendState? = null, val recipientList: MutableStateFlow> = MutableStateFlow(PagingData.empty()), val currentState: MutableStateFlow, ) @@ -65,11 +66,14 @@ internal sealed class SendStates { val receivedAmount: MutableStateFlow = MutableStateFlow(""), ) : SendStates() - // todo [REDACTED_JIRA] /** Send state */ data class SendState( - val isSuccess: Boolean, - ) + override val type: SendUiStateType = SendUiStateType.Send, + val isSending: MutableStateFlow = MutableStateFlow(false), + val isSuccess: MutableStateFlow = MutableStateFlow(false), + val transactionDate: MutableStateFlow = MutableStateFlow(0L), + val txUrl: MutableStateFlow = MutableStateFlow(""), + ) : SendStates() } enum class SendUiStateType { @@ -77,5 +81,4 @@ enum class SendUiStateType { Recipient, Fee, Send, - Done, } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt index e50ae05ae8..e92791bba9 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/StateRouter.kt @@ -9,28 +9,61 @@ internal class StateRouter( private val fragmentManager: WeakReference, ) { var currentState: MutableStateFlow = MutableStateFlow(SendUiStateType.Amount) + private set + + private var isFromSend: Boolean = false + + fun popBackStack() { + fragmentManager.get()?.popBackStack() + } fun onBackClick() { - fragmentManager.get()?.popBackStack() + if (isFromSend) { + showSend() + } else { + when (currentState.value) { + SendUiStateType.Amount -> popBackStack() + SendUiStateType.Recipient -> showAmount() + SendUiStateType.Fee -> showRecipient() + SendUiStateType.Send -> showFee() + } + } } fun onNextClick() { when (currentState.value) { - SendUiStateType.Amount -> currentState.update { SendUiStateType.Recipient } - SendUiStateType.Recipient -> currentState.update { SendUiStateType.Fee } - SendUiStateType.Fee -> currentState.update { SendUiStateType.Send } - SendUiStateType.Send -> currentState.update { SendUiStateType.Done } - SendUiStateType.Done -> onBackClick() + SendUiStateType.Amount -> showRecipient() + SendUiStateType.Recipient -> showFee() + SendUiStateType.Fee -> showSend() + SendUiStateType.Send -> onBackClick() } } fun onPrevClick() { when (currentState.value) { - SendUiStateType.Amount -> onBackClick() - SendUiStateType.Recipient -> currentState.update { SendUiStateType.Amount } - SendUiStateType.Fee -> currentState.update { SendUiStateType.Recipient } - SendUiStateType.Send -> currentState.update { SendUiStateType.Fee } - SendUiStateType.Done -> onBackClick() + SendUiStateType.Amount -> popBackStack() + SendUiStateType.Recipient -> showAmount() + SendUiStateType.Fee -> showRecipient() + SendUiStateType.Send -> popBackStack() } } + + fun showAmount(isFromSend: Boolean = false) { + this.isFromSend = isFromSend + currentState.update { SendUiStateType.Amount } + } + + fun showRecipient(isFromSend: Boolean = false) { + this.isFromSend = isFromSend + currentState.update { SendUiStateType.Recipient } + } + + fun showFee(isFromSend: Boolean = false) { + this.isFromSend = isFromSend + currentState.update { SendUiStateType.Fee } + } + + fun showSend() { + currentState.update { SendUiStateType.Send } + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt index 4b027a624d..9ab26bd83d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldConverter.kt @@ -24,7 +24,7 @@ internal class SendAmountFieldConverter( ), placeholder = TextReference.Str(DEFAULT_VALUE), isError = false, - error = TextReference.Res(R.string.send_insufficient_funds), + error = TextReference.Res(R.string.swapping_insufficient_funds), ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientMemoFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientMemoFieldConverter.kt index d1890038cb..a1c0bd5603 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientMemoFieldConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientMemoFieldConverter.kt @@ -45,6 +45,7 @@ internal class SendRecipientMemoFieldConverter( ), placeholder = TextReference.Res(R.string.send_optional_field), label = TextReference.Res(value), + error = TextReference.Res(R.string.send_memo_destination_tag_error), ), ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt index 30ca727485..e7c0df4249 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendNavigationButtons.kt @@ -1,23 +1,30 @@ package com.tangem.features.send.impl.presentation.ui +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility 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.State import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.R -import com.tangem.core.ui.components.PrimaryButton -import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.* +import com.tangem.core.ui.extensions.shareText import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiStateType @@ -64,16 +71,16 @@ private fun SendSecondaryNavigationButton(uiState: SendUiState) { @Composable private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier = Modifier) { - val currentState = uiState.currentState.collectAsState() + val currentState = uiState.currentState.collectAsStateWithLifecycle() + val isSuccess = uiState.sendState?.isSuccess?.collectAsStateWithLifecycle()?.value ?: false + val isSending = uiState.sendState?.isSending?.collectAsStateWithLifecycle()?.value ?: false + val txUrl = uiState.sendState?.txUrl?.collectAsStateWithLifecycle()?.value.orEmpty() - val buttonTextId = when (currentState.value) { - SendUiStateType.Amount, - SendUiStateType.Recipient, - SendUiStateType.Fee, - -> R.string.common_next - SendUiStateType.Send -> R.string.common_send - else -> R.string.common_close - } + val (buttonTextId, buttonClick) = getButtonData( + currentState = currentState, + isSuccess = isSuccess, + uiState = uiState, + ) val isButtonEnabled = when (currentState.value) { SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled ?: false @@ -86,19 +93,92 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier label = "Update send screen state", modifier = modifier, ) { textId -> - if (currentState.value == SendUiStateType.Send) { - PrimaryButtonIconEnd( - text = stringResource(textId), - iconResId = R.drawable.ic_tangem_24, - enabled = isButtonEnabled, - onClick = uiState.clickIntents::onNextClick, - ) - } else { - PrimaryButton( - text = stringResource(textId), - enabled = isButtonEnabled, - onClick = uiState.clickIntents::onNextClick, - ) + when { + currentState.value == SendUiStateType.Send && !isSuccess -> { + PrimaryButtonIconEnd( + text = stringResource(textId), + iconResId = R.drawable.ic_tangem_24, + enabled = isButtonEnabled, + onClick = buttonClick, + showProgress = isSending, + ) + } + currentState.value == SendUiStateType.Send && isSuccess -> { + PrimaryButtonsDone( + textRes = textId, + txUrl = txUrl, + onExploreClick = { uiState.clickIntents.onExploreClick(txUrl) }, + onDoneClick = buttonClick, + modifier = Modifier, + ) + } + else -> { + PrimaryButton( + text = stringResource(textId), + enabled = isButtonEnabled, + onClick = buttonClick, + ) + } } } +} + +@Composable +private fun PrimaryButtonsDone( + @StringRes textRes: Int, + txUrl: String, + onExploreClick: () -> Unit, + onDoneClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val hapticFeedback = LocalHapticFeedback.current + val context = LocalContext.current + + Column(modifier = modifier) { + if (txUrl.isNotBlank()) { + Row { + SecondaryButtonIconStart( + text = stringResource(id = R.string.common_explore), + iconResId = R.drawable.ic_web_24, + onClick = onExploreClick, + modifier = Modifier.weight(1f), + ) + SpacerW12() + SecondaryButtonIconStart( + text = stringResource(id = R.string.common_share), + iconResId = R.drawable.ic_share_24, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + context.shareText(txUrl) + }, + modifier = Modifier.weight(1f), + ) + } + SpacerH12() + } + PrimaryButton( + text = stringResource(id = textRes), + enabled = true, + onClick = onDoneClick, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +private fun getButtonData( + uiState: SendUiState, + currentState: State, + isSuccess: Boolean, +): Pair Unit> { + return when (currentState.value) { + SendUiStateType.Amount, + SendUiStateType.Recipient, + SendUiStateType.Fee, + -> R.string.common_next to uiState.clickIntents::onNextClick + SendUiStateType.Send -> if (isSuccess) { + R.string.common_close + } else { + R.string.common_send + } to uiState.clickIntents::onSendClick + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt index c8f289df54..1c73c25a04 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/SendScreen.kt @@ -22,11 +22,13 @@ import com.tangem.features.send.impl.presentation.state.SendUiStateType import com.tangem.features.send.impl.presentation.ui.amount.SendAmountContent import com.tangem.features.send.impl.presentation.ui.fee.SendSpeedAndFeeContent import com.tangem.features.send.impl.presentation.ui.recipient.SendRecipientContent +import com.tangem.features.send.impl.presentation.ui.send.SendContent @Composable internal fun SendScreen(uiState: SendUiState) { val currentState = uiState.currentState.collectAsStateWithLifecycle() - BackHandler { uiState.clickIntents.onPrevClick() } + val isSuccess = uiState.sendState?.isSuccess?.collectAsStateWithLifecycle() + BackHandler { uiState.clickIntents.onBackClick() } Column( modifier = Modifier .fillMaxSize() @@ -36,12 +38,10 @@ internal fun SendScreen(uiState: SendUiState) { horizontalAlignment = Alignment.CenterHorizontally, ) { val titleRes = when (currentState.value) { - SendUiStateType.Amount, - SendUiStateType.Send, - -> R.string.common_send + SendUiStateType.Amount -> R.string.common_send SendUiStateType.Recipient -> R.string.send_recipient SendUiStateType.Fee -> R.string.common_fee_selector_title - SendUiStateType.Done -> null + SendUiStateType.Send -> if (isSuccess?.value == false) R.string.common_send else null } val iconRes = when (currentState.value) { SendUiStateType.Amount, @@ -52,7 +52,7 @@ internal fun SendScreen(uiState: SendUiState) { AppBarWithBackButtonAndIcon( text = titleRes?.let { stringResource(it) }, - onBackClick = uiState.clickIntents::onBackClick, + onBackClick = uiState.clickIntents::popBackStack, onIconClick = uiState.clickIntents::onQrCodeScanClick, backIconRes = R.drawable.ic_close_24, iconRes = iconRes, @@ -94,7 +94,7 @@ private fun SendScreenContent( uiState.feeState, uiState.clickIntents, ) - else -> { /* [REDACTED_TODO_COMMENT]*/ } + SendUiStateType.Send -> SendContent(uiState) } } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt index 2fedacc5c1..f6471c020d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/amount/AmountField.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.style.TextAlign -import com.tangem.core.ui.components.fields.AmountVisualTransformation +import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendCustomFeeEthereum.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendCustomFeeEthereum.kt index a96d8c5f1b..98b24927f0 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendCustomFeeEthereum.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendCustomFeeEthereum.kt @@ -1,17 +1,21 @@ package com.tangem.features.send.impl.presentation.ui.fee +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.tangem.core.ui.components.fields.AmountVisualTransformation +import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation +import com.tangem.core.ui.components.inputrow.InputRowEnter +import com.tangem.core.ui.components.inputrow.InputRowEnterInfo +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.impl.R import com.tangem.features.send.impl.presentation.state.fee.FeeType import com.tangem.features.send.impl.presentation.state.fields.SendTextField -import com.tangem.features.send.impl.presentation.ui.recipient.TextFieldWithInfo +import com.tangem.features.send.impl.presentation.ui.common.FooterContainer private const val ETHEREUM_UNIT = "GWEI" @@ -22,42 +26,66 @@ internal fun SendCustomFeeEthereum( symbol: String, modifier: Modifier = Modifier, ) { - val fee = customValues.value[0] - val gasPrice = customValues.value[1] - val gasLimit = customValues.value[2] - if (selectedFee == FeeType.CUSTOM && customValues.value.isNotEmpty()) { + val fee = customValues.value[0] + val gasPrice = customValues.value[1] + val gasLimit = customValues.value[2] + Column( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), modifier = modifier, ) { - TextFieldWithInfo( - value = fee.value, - label = stringResource(R.string.send_max_fee), + FooterContainer( footer = stringResource(R.string.send_max_fee_footer), - info = fee.label, - visualTransformation = AmountVisualTransformation(symbol), - keyboardOptions = fee.keyboardOptions, - onValueChange = fee.onValueChange, - isSingleLine = true, - ) - TextFieldWithInfo( - value = gasPrice.value, - label = stringResource(R.string.send_gas_price), + ) { + InputRowEnterInfo( + text = fee.value, + title = TextReference.Res(R.string.send_max_fee), + info = fee.label, + visualTransformation = AmountVisualTransformation(symbol), + keyboardOptions = fee.keyboardOptions, + onValueChange = fee.onValueChange, + isSingleLine = true, + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) + } + FooterContainer( footer = stringResource(R.string.send_gas_price_footer), - onValueChange = gasPrice.onValueChange, - visualTransformation = AmountVisualTransformation(ETHEREUM_UNIT), - keyboardOptions = fee.keyboardOptions, - isSingleLine = true, - ) - TextFieldWithInfo( - value = gasLimit.value, - label = stringResource(R.string.send_gas_limit), + ) { + InputRowEnter( + text = gasPrice.value, + title = TextReference.Res(R.string.send_gas_price), + onValueChange = gasPrice.onValueChange, + visualTransformation = AmountVisualTransformation(ETHEREUM_UNIT), + keyboardOptions = fee.keyboardOptions, + isSingleLine = true, + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) + } + FooterContainer( footer = stringResource(R.string.send_gas_limit_footer), - onValueChange = gasLimit.onValueChange, - keyboardOptions = fee.keyboardOptions, - isSingleLine = true, - ) + ) { + InputRowEnter( + text = gasLimit.value, + title = TextReference.Res(R.string.send_gas_limit), + onValueChange = gasLimit.onValueChange, + keyboardOptions = fee.keyboardOptions, + isSingleLine = true, + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) + } } } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt index 30563155b4..24319091e1 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/SendRecipientContent.kt @@ -18,11 +18,13 @@ import androidx.compose.ui.res.stringResource import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey +import com.tangem.core.ui.components.inputrow.InputRowRecipient import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.impl.R import com.tangem.features.send.impl.presentation.domain.SendRecipientListContent import com.tangem.features.send.impl.presentation.state.SendStates +import com.tangem.features.send.impl.presentation.ui.common.FooterContainer import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY" @@ -45,18 +47,26 @@ internal fun SendRecipientContent( .padding(horizontal = TangemTheme.dimens.spacing16), ) { item(key = ADDRESS_FIELD_KEY) { - TextFieldWithPasteAndIcon( - value = address.value, - label = address.label, - placeholder = address.placeholder, + FooterContainer( footer = stringResource(R.string.send_recipient_address_footer, uiState.network), - onValueChange = address.onValueChange, - onPasteClick = clickIntents::onRecipientAddressValueChange, - singleLine = true, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing4), - isError = address.isError, - error = address.error, - ) + ) { + InputRowRecipient( + value = address.value, + title = address.label, + placeholder = address.placeholder, + onValueChange = address.onValueChange, + onPasteClick = clickIntents::onRecipientAddressValueChange, + singleLine = true, + isError = address.isError, + error = address.error, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing4) + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) + } } memo?.let { memoField -> item(key = MEMO_FIELD_KEY) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFieldWithPaste.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFieldWithPaste.kt new file mode 100644 index 0000000000..8b3c83bfb0 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFieldWithPaste.kt @@ -0,0 +1,70 @@ +package com.tangem.features.send.impl.presentation.ui.recipient + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.fields.SimpleTextField +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.features.send.impl.presentation.ui.common.FooterContainer + +@Composable +internal fun TextFieldWithPaste( + value: String, + placeholder: TextReference, + label: TextReference, + onValueChange: (String) -> Unit, + onPasteClick: (String) -> Unit, + modifier: Modifier = Modifier, + footer: String? = null, + error: TextReference? = null, + isError: Boolean = false, +) { + val (title, color) = if (isError && error != null) { + error to TangemTheme.colors.text.warning + } else { + label to TangemTheme.colors.text.secondary + } + FooterContainer(modifier, footer) { + Row( + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(TangemTheme.dimens.spacing12), + ) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography.body2, + color = color, + ) + SimpleTextField( + value = value, + placeholder = placeholder, + onValueChange = onValueChange, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing6), + ) + } + PasteButton( + isPasteButtonVisible = value.isBlank(), + onClick = onPasteClick, + modifier = Modifier + .align(CenterVertically) + .padding(end = TangemTheme.dimens.spacing16), + ) + } + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFields.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFields.kt deleted file mode 100644 index f2a792f03e..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/TextFields.kt +++ /dev/null @@ -1,385 +0,0 @@ -package com.tangem.features.send.impl.presentation.ui.recipient - -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.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.SpacerH8 -import com.tangem.core.ui.components.icons.identicon.IdentIcon -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.impl.R -import com.tangem.features.send.impl.presentation.ui.common.FooterContainer - -@Composable -internal fun TextFieldWithPasteAndIcon( - value: String, - placeholder: TextReference, - label: TextReference, - onValueChange: (String) -> Unit, - onPasteClick: (String) -> Unit, - modifier: Modifier = Modifier, - footer: String? = null, - singleLine: Boolean = false, - error: TextReference? = null, - isError: Boolean = false, -) { - val (title, color) = if (isError && error != null) { - error to TangemTheme.colors.text.warning - } else { - label to TangemTheme.colors.text.secondary - } - FooterContainer(modifier, footer) { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - color = TangemTheme.colors.background.action, - shape = TangemTheme.shapes.roundedCornersXMedium, - ), - ) { - Text( - text = title.resolveReference(), - style = TangemTheme.typography.body2, - color = color, - modifier = Modifier - .padding( - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing12, - ), - ) - Row { - IdentIcon( - address = value, - modifier = Modifier - .padding( - start = TangemTheme.dimens.spacing16, - top = TangemTheme.dimens.spacing8, - bottom = TangemTheme.dimens.spacing10, - ) - .clip(RoundedCornerShape(TangemTheme.dimens.radius20)) - .size(TangemTheme.dimens.size40) - .background(TangemTheme.colors.background.tertiary), - ) - SimpleTextField( - value = value, - placeholder = placeholder, - onValueChange = onValueChange, - singleLine = singleLine, - modifier = Modifier - .padding( - start = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing8, - bottom = TangemTheme.dimens.spacing10, - ) - .weight(1f) - .align(CenterVertically), - ) - PasteButton( - isPasteButtonVisible = value.isBlank(), - onClick = onPasteClick, - modifier = Modifier - .align(CenterVertically) - .padding( - start = TangemTheme.dimens.spacing4, - end = TangemTheme.dimens.spacing16, - ), - ) - } - } - } -} - -@Composable -internal fun TextFieldWithPaste( - value: String, - placeholder: TextReference, - label: TextReference, - onValueChange: (String) -> Unit, - onPasteClick: (String) -> Unit, - modifier: Modifier = Modifier, - footer: String? = null, - error: TextReference? = null, - isError: Boolean = false, -) { - val (title, color) = if (isError && error != null) { - error to TangemTheme.colors.text.warning - } else { - label to TangemTheme.colors.text.secondary - } - FooterContainer(modifier, footer) { - Row( - modifier = Modifier - .background( - color = TangemTheme.colors.background.action, - shape = TangemTheme.shapes.roundedCornersXMedium, - ), - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(TangemTheme.dimens.spacing12), - ) { - Text( - text = title.resolveReference(), - style = TangemTheme.typography.body2, - color = color, - ) - SimpleTextField( - value = value, - placeholder = placeholder, - onValueChange = onValueChange, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing6), - ) - } - PasteButton( - isPasteButtonVisible = value.isBlank(), - onClick = onPasteClick, - modifier = Modifier - .align(CenterVertically) - .padding(end = TangemTheme.dimens.spacing16), - ) - } - } -} - -@Composable -internal fun TextFieldWithInfo( - value: String, - label: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - info: TextReference? = null, - footer: String? = null, - isSingleLine: Boolean = false, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, -) { - FooterContainer( - footer = footer, - footerTopPadding = TangemTheme.dimens.spacing6, - modifier = modifier, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - color = TangemTheme.colors.background.action, - shape = TangemTheme.shapes.roundedCornersXMedium, - ) - .padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - top = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing14, - ), - ) { - Text( - text = label, - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.secondary, - ) - Row { - SimpleTextField( - value = value, - onValueChange = onValueChange, - visualTransformation = visualTransformation, - singleLine = isSingleLine, - keyboardOptions = keyboardOptions, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing6) - .weight(1f), - ) - info?.let { - Text( - text = it.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier - .padding(start = TangemTheme.dimens.spacing8) - .align(Alignment.Bottom), - ) - } - } - } - } -} - -@Composable -private fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifier: Modifier = Modifier) { - val clipboardManager = LocalClipboardManager.current - val hapticFeedback = LocalHapticFeedback.current - - if (isPasteButtonVisible) { - Box(modifier = modifier) { - Text( - text = "Paste", - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary2, - modifier = Modifier - .background( - color = TangemTheme.colors.button.primary, - shape = TangemTheme.shapes.roundedCornersXMedium, - ) - .padding( - horizontal = TangemTheme.dimens.spacing10, - vertical = TangemTheme.dimens.spacing2, - ) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(radius = TangemTheme.dimens.radius8), - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - onClick( - clipboardManager - .getText() - ?.toString() - .orEmpty(), - ) - }, - ), - ) - } - } else { - Icon( - painter = painterResource(id = R.drawable.ic_close_24), - tint = TangemTheme.colors.icon.informative, - contentDescription = stringResource(R.string.common_close), - modifier = modifier - .size(TangemTheme.dimens.size20) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(radius = TangemTheme.dimens.radius10), - onClick = { onClick("") }, - ), - ) - } -} - -@Composable -private fun SimpleTextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - placeholder: TextReference? = null, - singleLine: Boolean = false, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, -) { - val focusRequester = remember { FocusRequester() } - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TangemTheme.typography.body2.copy(color = TangemTheme.colors.text.primary1), - cursorBrush = SolidColor(TangemTheme.colors.text.primary1), - singleLine = singleLine, - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - decorationBox = { textValue -> - Box { - if (value.isBlank() && placeholder != null) { - Text( - text = placeholder.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.disabled, - modifier = Modifier, - ) - } - textValue() - } - }, - modifier = modifier - .focusRequester(focusRequester), - ) -} - -//region preview -@Preview -@Composable -private fun TextFieldPreview_Light() { - TangemTheme { - Column { - TextFieldWithPaste( - value = "", - label = TextReference.Res(R.string.send_recipient), - placeholder = TextReference.Res(R.string.send_enter_address_field), - onValueChange = {}, - onPasteClick = {}, - ) - SpacerH8() - TextFieldWithPasteAndIcon( - value = "", - label = TextReference.Res(R.string.send_extras_hint_memo), - placeholder = TextReference.Res(R.string.send_optional_field), - onValueChange = {}, - onPasteClick = {}, - ) - SpacerH8() - TextFieldWithInfo( - value = "Text", - label = stringResource(R.string.send_extras_hint_memo), - info = TextReference.Res(R.string.send_optional_field), - footer = stringResource(R.string.send_max_fee), - onValueChange = {}, - ) - } - } -} - -@Preview -@Composable -private fun TextFieldPreview_Dark() { - TangemTheme(isDark = true) { - Column { - TextFieldWithPaste( - value = "", - label = TextReference.Res(R.string.send_recipient), - placeholder = TextReference.Res(R.string.send_enter_address_field), - onValueChange = {}, - onPasteClick = {}, - ) - SpacerH8() - TextFieldWithPasteAndIcon( - value = "", - label = TextReference.Res(R.string.send_extras_hint_memo), - placeholder = TextReference.Res(R.string.send_optional_field), - onValueChange = {}, - onPasteClick = {}, - ) - SpacerH8() - TextFieldWithInfo( - value = "Text", - label = stringResource(R.string.send_extras_hint_memo), - info = TextReference.Res(R.string.send_optional_field), - footer = stringResource(R.string.send_max_fee), - onValueChange = {}, - ) - } - } -} -//endregion \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt new file mode 100644 index 0000000000..037913ef69 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/send/SendContent.kt @@ -0,0 +1,193 @@ +package com.tangem.features.send.impl.presentation.ui.send + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchain.extensions.toBigDecimalOrDefault +import com.tangem.core.ui.components.inputrow.InputRowDefault +import com.tangem.core.ui.components.inputrow.InputRowImage +import com.tangem.core.ui.components.inputrow.InputRowRecipientDefault +import com.tangem.core.ui.components.transactions.TransactionDoneTitle +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.BigDecimalFormatter.formatCryptoAmount +import com.tangem.features.send.impl.R +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.fee.FeeSelectorState +import com.tangem.features.send.impl.presentation.state.fee.FeeType + +@Suppress("LongMethod") +@Composable +internal fun SendContent(uiState: SendUiState) { + val amountState = uiState.amountState ?: return + val recipientState = uiState.recipientState ?: return + val feeState = uiState.feeState ?: return + val sendState = uiState.sendState ?: return + + val isSuccess = sendState.isSuccess.collectAsStateWithLifecycle() + val timestamp = sendState.transactionDate.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + AnimatedVisibility(visible = isSuccess.value) { + TransactionDoneTitle( + titleRes = R.string.sent_transaction_sent_title, + date = timestamp.value, + ) + } + AnimatedVisibility(visible = !isSuccess.value) { + FromWallet( + walletName = amountState.walletName, + walletBalance = amountState.walletBalance, + ) + } + AmountBlock( + amountState = amountState, + isSuccess = isSuccess, + onClick = uiState.clickIntents::showAmount, + ) + RecipientBlock( + recipientState = recipientState, + isSuccess = isSuccess, + onClick = uiState.clickIntents::showRecipient, + ) + FeeBlock( + feeState = feeState, + isSuccess = isSuccess, + onClick = uiState.clickIntents::showFee, + ) + } +} + +@Composable +private fun FromWallet(walletName: String, walletBalance: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.button.disabled) + .padding(TangemTheme.dimens.spacing12), + ) { + Text( + text = buildAnnotatedString { + append(stringResource(R.string.send_from_wallet_android)) + append(" ") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(walletName) + } + }, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) + Text( + text = walletBalance, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding( + top = TangemTheme.dimens.spacing8, + ), + ) + } +} + +@Composable +private fun AmountBlock(amountState: SendStates.AmountState, isSuccess: State, onClick: () -> Unit) { + val amount = amountState.amountTextField.collectAsStateWithLifecycle() + + val cryptoAmount = formatCryptoAmount( + cryptoCurrency = amountState.cryptoCurrencyStatus.currency, + cryptoAmount = amount.value.value.toBigDecimalOrDefault(), + ) + val fiatAmount = BigDecimalFormatter.formatFiatAmount( + fiatAmount = amount.value.fiatValue.toBigDecimalOrDefault(), + fiatCurrencyCode = amountState.appCurrency.code, + fiatCurrencySymbol = amountState.appCurrency.symbol, + ) + InputRowImage( + title = TextReference.Res(R.string.send_amount_label), + subtitle = TextReference.Str(cryptoAmount), + caption = TextReference.Str(fiatAmount), + tokenIconState = amountState.tokenIconState, + showNetworkIcon = true, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .clickable(enabled = !isSuccess.value) { onClick() }, + ) +} + +@Composable +private fun RecipientBlock(recipientState: SendStates.RecipientState, isSuccess: State, onClick: () -> Unit) { + val address = recipientState.addressTextField.collectAsStateWithLifecycle() + val memo = recipientState.memoTextField?.collectAsStateWithLifecycle() + + Column( + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .clickable(enabled = !isSuccess.value) { onClick() }, + ) { + val showMemo = memo != null && memo.value.value.isNotBlank() + InputRowRecipientDefault( + title = TextReference.Res(R.string.send_recipient), + value = address.value.value, + showDivider = showMemo, + ) + if (showMemo) { + InputRowDefault( + title = TextReference.Res(R.string.send_extras_hint_memo), + text = TextReference.Str(memo?.value?.value.orEmpty()), + ) + } + } +} + +@Composable +private fun FeeBlock(feeState: SendStates.FeeState, isSuccess: State, onClick: () -> Unit) { + val feeSelector = + feeState.feeSelectorState.collectAsStateWithLifecycle().value as? FeeSelectorState.Content ?: return + val customValue = feeSelector.customValues.collectAsStateWithLifecycle().value.getOrNull(0) + + val feeValue = formatCryptoAmount( + cryptoCurrency = feeState.cryptoCurrencyStatus.currency, + cryptoAmount = when (val selectedFee = feeSelector.fees) { + is TransactionFee.Single -> selectedFee.normal.amount.value + is TransactionFee.Choosable -> when (feeSelector.selectedFee) { + FeeType.SLOW -> selectedFee.minimum.amount.value + FeeType.MARKET -> selectedFee.normal.amount.value + FeeType.FAST -> selectedFee.priority.amount.value + FeeType.CUSTOM -> customValue?.value.toBigDecimalOrDefault() + } + }, + ) + InputRowDefault( + title = TextReference.Res(R.string.send_network_fee_title), + text = TextReference.Str(feeValue), + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .clickable(enabled = !isSuccess.value) { onClick() }, + ) +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/MemoVerification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/MemoVerification.kt index a27ce13f23..2e63cb625f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/MemoVerification.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/MemoVerification.kt @@ -7,6 +7,7 @@ import java.math.BigInteger internal fun validateMemo(memo: String, cryptoCurrency: CryptoCurrency?): Boolean { if (cryptoCurrency == null) return false + if (memo.isEmpty()) return true return when (cryptoCurrency.network.id.value) { Blockchain.XRP.id -> { val tag = memo.toLongOrNull() @@ -40,7 +41,12 @@ private fun isAssignableValue(value: String): Boolean { } } -private enum class XlmMemoType { TEXT, ID } +fun determineXlmMemoType(value: String): XlmMemoType = when { + value.isNotEmpty() && value.isDigitsOnly() -> XlmMemoType.ID + else -> XlmMemoType.TEXT +} + +enum class XlmMemoType { TEXT, ID } private const val XRP_TAG_MAX_NUMBER = 4294967295 private const val XLM_MEMO_MAX_LENGTH = 28 \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt index 35df0a6614..d29ab95bee 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendClickIntents.kt @@ -4,6 +4,8 @@ import com.tangem.features.send.impl.presentation.state.fee.FeeType interface SendClickIntents { + fun popBackStack() + fun onBackClick() fun onNextClick() @@ -33,4 +35,16 @@ interface SendClickIntents { fun onSubtractSelect(value: Boolean) // endregion + + // region Send + fun onSendClick() + + fun showAmount() + + fun showRecipient() + + fun showFee() + + fun onExploreClick(txUrl: String) + // endregion } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt index 02f2caefc6..7d29e9d5cd 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt @@ -6,9 +6,17 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.* import androidx.paging.PagingData import arrow.core.getOrElse +import com.tangem.blockchain.blockchains.binance.BinanceTransactionExtras +import com.tangem.blockchain.blockchains.cosmos.CosmosTransactionExtras +import com.tangem.blockchain.blockchains.stellar.StellarMemo +import com.tangem.blockchain.blockchains.stellar.StellarTransactionExtras +import com.tangem.blockchain.blockchains.ton.TonTransactionExtras import com.tangem.blockchain.blockchains.xrp.XrpAddressService +import com.tangem.blockchain.blockchains.xrp.XrpTransactionBuilder import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionExtras import com.tangem.blockchain.common.address.Address +import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.Provider import com.tangem.core.ui.utils.BigDecimalFormatter @@ -18,8 +26,11 @@ import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.utils.convertToAmount import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase import com.tangem.domain.walletmanager.WalletManagersFacade @@ -28,6 +39,7 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.api.navigation.SendRouter +import com.tangem.features.send.impl.navigation.InnerSendRouter import com.tangem.features.send.impl.presentation.domain.AvailableWallet import com.tangem.features.send.impl.presentation.state.SendStateFactory import com.tangem.features.send.impl.presentation.state.SendUiState @@ -48,7 +60,7 @@ import java.math.BigDecimal import javax.inject.Inject import kotlin.properties.Delegates -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") @HiltViewModel internal class SendViewModel @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, @@ -60,6 +72,8 @@ internal class SendViewModel @Inject constructor( private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase, private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val getFeeUseCase: GetFeeUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val walletManagersFacade: WalletManagersFacade, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver, SendClickIntents { @@ -73,7 +87,8 @@ internal class SendViewModel @Inject constructor( private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() - private var innerRouter: StateRouter by Delegates.notNull() + private var innerRouter: InnerSendRouter by Delegates.notNull() + private var stateRouter: StateRouter by Delegates.notNull() private val stateFactory = SendStateFactory( clickIntents = this, @@ -102,9 +117,10 @@ internal class SendViewModel @Inject constructor( getFee() } - fun setRouter(router: StateRouter) { + fun setRouter(router: InnerSendRouter, stateRouter: StateRouter) { innerRouter = router - uiState = uiState.copy(currentState = router.currentState) + this.stateRouter = stateRouter + uiState = uiState.copy(currentState = stateRouter.currentState) } private fun subscribeOnCurrencyStatusUpdates(owner: LifecycleOwner) { @@ -271,9 +287,10 @@ internal class SendViewModel @Inject constructor( } // region screen state navigation - override fun onBackClick() = innerRouter.onBackClick() - override fun onNextClick() = innerRouter.onNextClick() - override fun onPrevClick() = innerRouter.onPrevClick() + override fun popBackStack() = stateRouter.popBackStack() + override fun onBackClick() = stateRouter.onBackClick() + override fun onNextClick() = stateRouter.onNextClick() + override fun onPrevClick() = stateRouter.onPrevClick() override fun onQrCodeScanClick() { // TODO Add QR code scanning @@ -314,7 +331,7 @@ internal class SendViewModel @Inject constructor( } private fun checkIfXrpAddressValue(value: String): Boolean { - if (cryptoCurrency.network.id.value == Blockchain.XRP.id && value.first() == XRP_X_ADDRESS) { + if (cryptoCurrency.network.id.value == Blockchain.XRP.id && value.firstOrNull() == XRP_X_ADDRESS) { viewModelScope.launch(dispatchers.io) { val result = XrpAddressService.decodeXAddress(value) onRecipientAddressValueChange(result?.address.orEmpty()) @@ -382,8 +399,127 @@ internal class SendViewModel @Inject constructor( } //endregion + // region send state clicks + override fun onSendClick() { + val sendState = uiState.sendState ?: return + + if (sendState.isSuccess.value) popBackStack() + sendState.isSending.update { true } + viewModelScope.launch(dispatchers.io) { + verifyAndSendTransaction() + } + } + + private suspend fun verifyAndSendTransaction() { + val sendState = uiState.sendState ?: return + val amount = uiState.amountState?.amountTextField?.value ?: return + val recipient = uiState.recipientState?.addressTextField?.value ?: return + val feeState = uiState.feeState?.feeSelectorState?.value as? FeeSelectorState.Content ?: return + val memo = uiState.recipientState?.memoTextField?.value + val fee = getFee(feeState) ?: return + + val amountToSend = amount.value.toBigDecimal().convertToAmount(cryptoCurrency) + + // todo add notifications [[REDACTED_JIRA]] + // val transactionErrors = walletManagersFacade.validateTransaction( + // amount = amountToSend, + // fee = fee.amount, + // userWalletId = userWalletId, + // network = cryptoCurrency.network, + // ) + + val txData = walletManagersFacade.createTransaction( + amount = amountToSend, + fee = fee, + memo = memo?.value, + destination = recipient.value, + userWalletId = userWalletId, + network = cryptoCurrency.network, + )?.copy(extras = getMemoExtras(cryptoCurrency.network.id.value, memo?.value)) ?: return + + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = cryptoCurrency.network, + ).fold( + ifLeft = { + sendState.isSending.update { false } + // todo add notifications [[REDACTED_JIRA]] + }, + ifRight = { + sendState.transactionDate.update { + txData.date?.timeInMillis ?: System.currentTimeMillis() + } + sendState.isSuccess.update { true } + sendState.txUrl.update { + getTxUrl(txData.hash.orEmpty()) + } + }, + ) + } + + private fun getFee(feeState: FeeSelectorState.Content): Fee? { + return when (val selectedFee = feeState.fees) { + is TransactionFee.Choosable -> { + when (feeState.selectedFee) { + FeeType.SLOW -> selectedFee.minimum + FeeType.MARKET -> selectedFee.normal + FeeType.FAST -> selectedFee.priority + FeeType.CUSTOM -> { + val feeAmount = feeState.customValues.value.firstOrNull()?.value + ?.let { BigDecimal(it) } ?: return null + Fee.Common(feeAmount.convertToAmount(cryptoCurrency)) + } + } + } + is TransactionFee.Single -> selectedFee.normal + } + } + + override fun showAmount() = stateRouter.showAmount(isFromSend = true) + + override fun showRecipient() = stateRouter.showRecipient(isFromSend = true) + + override fun showFee() = stateRouter.showFee(isFromSend = true) + + override fun onExploreClick(txUrl: String) = innerRouter.openUrl(txUrl) + + private fun getTxUrl(hash: String): String { + val blockchain = Blockchain.fromId(cryptoCurrency.network.id.value) + // TODO: Fix ton tx urls [REDACTED_TASK_KEY] + return if (blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet) { + EMPTY + } else { + getExplorerTransactionUrlUseCase( + txHash = hash, + networkId = cryptoCurrency.network.id, + ) + } + } + // endregion + + private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? { + val blockchain = Blockchain.fromId(networkId) + if (memo == null) return null + return when (blockchain) { + Blockchain.Stellar -> { + val xmlMemo = when (determineXlmMemoType(memo)) { + XlmMemoType.TEXT -> StellarMemo.Text(memo) + XlmMemoType.ID -> StellarMemo.Id(memo.toBigInteger()) + } + StellarTransactionExtras(xmlMemo) + } + Blockchain.Binance -> BinanceTransactionExtras(memo) + Blockchain.XRP -> memo.toLongOrNull()?.let { XrpTransactionBuilder.XrpTransactionExtras(it) } + Blockchain.Cosmos -> CosmosTransactionExtras(memo) + Blockchain.TON -> TonTransactionExtras(memo) + else -> null + } + } + companion object { private const val XRP_X_ADDRESS = 'X' private const val DEFAULT_VALUE = "0.00" + private const val EMPTY = "" } } \ No newline at end of file diff --git a/features/swap/data/build.gradle.kts b/features/swap/data/build.gradle.kts index 67d016525d..53dddfc5a9 100644 --- a/features/swap/data/build.gradle.kts +++ b/features/swap/data/build.gradle.kts @@ -8,20 +8,25 @@ plugins { dependencies { + /** AndroidX */ + implementation(deps.androidx.datastore) + /** Project*/ - implementation(project(":core:datasource")) - implementation(project(":core:utils")) - implementation(project(":features:swap:domain")) + implementation(projects.core.datasource) + implementation(projects.core.utils) + implementation(projects.features.swap.domain) /** Network */ implementation(deps.retrofit) implementation(deps.moshi) implementation(deps.moshi.kotlin) + implementation(deps.arrow.core) /** Domain */ implementation(projects.domain.tokens.models) implementation(projects.domain.legacy) implementation(projects.domain.models) + implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) /** Data */ @@ -32,5 +37,6 @@ dependencies { /** DI */ implementation(deps.hilt.android) + kapt(deps.hilt.kapt) } \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt new file mode 100644 index 0000000000..97b9a1e2b2 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -0,0 +1,197 @@ +package com.tangem.feature.swap + +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.getObjectList +import com.tangem.datasource.local.preferences.utils.getObjectListSync +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.swap.domain.SwapTransactionRepository +import com.tangem.feature.swap.domain.models.domain.SavedLastSwappedCryptoCurrency +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel +import com.tangem.utils.extensions.addOrReplace +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class DefaultSwapTransactionRepository( + private val appPreferencesStore: AppPreferencesStore, +) : SwapTransactionRepository { + + override suspend fun storeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + transaction: SavedSwapTransactionModel, + ) { + appPreferencesStore.editData { mutablePreferences -> + val savedTransactions: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ) + + val tokenTransactions = savedTransactions + ?.firstOrNull { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrencyId, + toCurrencyId = toCryptoCurrencyId, + ) + } + ?.transactions + ?.addOrReplace( + item = transaction, + predicate = { it.txId == transaction.txId }, + ) ?: listOf(transaction) + + mutablePreferences.setObject( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + value = savedTransactions?.updateList( + userWalletId = userWalletId, + fromCryptoCurrencyId = fromCryptoCurrencyId, + toCryptoCurrencyId = toCryptoCurrencyId, + transactions = tokenTransactions, + ) ?: listOf( + SavedSwapTransactionListModel( + userWalletId = userWalletId.stringValue, + fromCryptoCurrencyId = fromCryptoCurrencyId.value, + toCryptoCurrencyId = toCryptoCurrencyId.value, + transactions = tokenTransactions, + ), + ), + ) + } + } + + override fun getTransactions( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): Flow?> { + return appPreferencesStore.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ).map { savedTransactions -> + savedTransactions + ?.filter { + it.userWalletId == userWalletId.stringValue && + ( + it.toCryptoCurrencyId == cryptoCurrencyId.value || + it.fromCryptoCurrencyId == cryptoCurrencyId.value + ) + } + } + } + + override suspend fun removeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + txId: String, + ) { + appPreferencesStore.editData { mutablePreferences -> + val savedList: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + ) + val tokenTransactions = savedList + ?.first { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrencyId, + toCurrencyId = toCryptoCurrencyId, + ) + } + ?.transactions + ?.filterNot { it.txId == txId } + + val editedList = + if (tokenTransactions.isNullOrEmpty()) { + savedList?.filterNot { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrencyId, + toCurrencyId = toCryptoCurrencyId, + ) + } + } else { + savedList.updateList( + userWalletId = userWalletId, + fromCryptoCurrencyId = fromCryptoCurrencyId, + toCryptoCurrencyId = toCryptoCurrencyId, + transactions = tokenTransactions, + ) + } + + if (editedList.isNullOrEmpty()) { + mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_KEY) + } else { + mutablePreferences.setObject( + key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, + value = editedList, + ) + } + } + } + + override suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? { + val lastSwappedCurrencies = appPreferencesStore.getObjectListSync( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + ) + + return lastSwappedCurrencies.find { userWalletId.stringValue == it.userWalletId }?.cryptoCurrencyId + } + + override suspend fun storeLastSwappedCryptoCurrencyId( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ) { + appPreferencesStore.editData { mutablePreferences -> + val lastSwappedCryptoCurrencies: List? = mutablePreferences.getObjectList( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + ) + + val newList = if (lastSwappedCryptoCurrencies != null) { + lastSwappedCryptoCurrencies.filter { + it.userWalletId != userWalletId.stringValue + } + SavedLastSwappedCryptoCurrency(userWalletId.stringValue, cryptoCurrencyId.value) + } else { + listOf(SavedLastSwappedCryptoCurrency(userWalletId.stringValue, cryptoCurrencyId.value)) + } + + mutablePreferences.setObjectList( + key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY, + value = newList, + ) + } + } + + private fun SavedSwapTransactionListModel.checkId( + checkUserWalletId: UserWalletId, + fromCurrencyId: CryptoCurrency.ID, + toCurrencyId: CryptoCurrency.ID, + ): Boolean { + return userWalletId == checkUserWalletId.stringValue && + toCryptoCurrencyId == toCurrencyId.value && + fromCryptoCurrencyId == fromCurrencyId.value + } + + private fun List.updateList( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + transactions: List, + ): List { + return addOrReplace( + item = SavedSwapTransactionListModel( + userWalletId = userWalletId.stringValue, + fromCryptoCurrencyId = fromCryptoCurrencyId.value, + toCryptoCurrencyId = toCryptoCurrencyId.value, + transactions = transactions, + ), + predicate = { + it.checkId( + checkUserWalletId = userWalletId, + fromCurrencyId = fromCryptoCurrencyId, + toCurrencyId = toCryptoCurrencyId, + ) + }, + ) + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt index d9ee8d861a..90358d75df 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/SwapRepositoryImpl.kt @@ -1,51 +1,130 @@ package com.tangem.feature.swap +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.Approver import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token import com.tangem.blockchain.extensions.Result import com.tangem.data.tokens.utils.CryptoCurrencyFactory +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.getOrThrow +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.request.PairsRequestBody +import com.tangem.datasource.api.express.models.response.SwapPair +import com.tangem.datasource.api.express.models.response.SwapPairsWithProviders import com.tangem.datasource.api.oneinch.OneInchApi import com.tangem.datasource.api.oneinch.OneInchApiFactory -import com.tangem.datasource.api.oneinch.OneInchErrorsHandler -import com.tangem.datasource.api.oneinch.errors.OneIncResponseException import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.config.ConfigManager import com.tangem.domain.common.extensions.fromNetworkId import com.tangem.domain.common.util.derivationStyleProvider 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.domain.wallets.legacy.WalletsStateHolder import com.tangem.domain.wallets.models.UserWalletId -import com.tangem.feature.swap.converters.QuotesConverter -import com.tangem.feature.swap.converters.SwapConverter -import com.tangem.feature.swap.converters.TokensConverter +import com.tangem.feature.swap.converters.* import com.tangem.feature.swap.domain.SwapRepository +import com.tangem.feature.swap.domain.models.DataError +import com.tangem.feature.swap.domain.models.createFromAmountWithOffset import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.QuoteModel -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.mapErrors +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.async import kotlinx.coroutines.withContext import java.math.BigDecimal import javax.inject.Inject -import com.tangem.blockchain.common.Token as SdkToken +import com.tangem.datasource.api.express.models.request.LeastTokenInfo as NetworkLeastTokenInfo +@Suppress("LongParameterList") internal class SwapRepositoryImpl @Inject constructor( private val tangemTechApi: TangemTechApi, + private val tangemExpressApi: TangemExpressApi, private val oneInchApiFactory: OneInchApiFactory, - private val oneInchErrorsHandler: OneInchErrorsHandler, private val coroutineDispatcher: CoroutineDispatcherProvider, private val configManager: ConfigManager, private val walletManagersFacade: WalletManagersFacade, + private val walletsStateHolder: WalletsStateHolder, + private val errorsDataConverter: ErrorsDataConverter, ) : SwapRepository { private val tokensConverter = TokensConverter() - private val quotesConverter = QuotesConverter() - private val swapConverter = SwapConverter() + private val expressDataConverter = ExpressDataConverter() + private val leastTokenInfoConverter = LeastTokenInfoConverter() + private val swapPairInfoConverter = SwapPairInfoConverter() + private val cryptoCurrencyFactory = CryptoCurrencyFactory() + private val exchangeStatusConverter = ExchangeStatusConverter() + + override suspend fun getPairs( + initialCurrency: LeastTokenInfo, + currencyList: List, + ): List { + return withContext(coroutineDispatcher.io) { + val initial = NetworkLeastTokenInfo( + contractAddress = initialCurrency.contractAddress, + network = initialCurrency.network, + ) + val currenciesList = currencyList.map { leastTokenInfoConverter.convert(it) } + + val pairs = async { + getPairsInternal( + from = arrayListOf(initial), + to = currenciesList, + ) + } + + val reversedPairs = async { + getPairsInternal( + from = currenciesList, + to = arrayListOf(initial), + ) + } + + val allPairs = pairs.await() + reversedPairs.await() + + val providers = tangemExpressApi.getProviders().getOrThrow() + + return@withContext swapPairInfoConverter.convert( + SwapPairsWithProviders( + swapPair = allPairs, + providers = providers, + ), + ) + } + } + + private suspend fun getPairsInternal( + from: List, + to: List, + ): List { + return tangemExpressApi.getPairs( + PairsRequestBody( + from = from, + to = to, + ), + ).getOrThrow() + } + + override suspend fun getExchangeStatus(txId: String): Either { + return withContext(coroutineDispatcher.io) { + either { + catch( + { + exchangeStatusConverter.convert( + tangemExpressApi + .getExchangeStatus(txId) + .getOrThrow(), + ) + }, + { + raise(UnknownError(it.message)) + }, + ) + } + } + } override suspend fun getRates(currencyId: String, tokenIds: List): Map { // workaround cause backend do not return arbitrum and optimism rates @@ -83,23 +162,37 @@ internal class SwapRepositoryImpl @Inject constructor( } override suspend fun findBestQuote( - networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - amount: String, + fromContractAddress: String, + fromNetwork: String, + toContractAddress: String, + toNetwork: String, + fromAmount: String, + fromDecimals: Int, + toDecimals: Int, + providerId: String, + rateType: RateType, ): AggregatedSwapDataModel { return withContext(coroutineDispatcher.io) { try { - val response = oneInchErrorsHandler.handleOneInchResponse( - getOneInchApi(networkId).quote( - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, - amount = amount, + val response = tangemExpressApi.getExchangeQuote( + fromContractAddress = fromContractAddress, + fromNetwork = fromNetwork, + toContractAddress = toContractAddress, + toNetwork = toNetwork, + fromAmount = fromAmount, + fromDecimals = fromDecimals, + toDecimals = toDecimals, + providerId = providerId, + rateType = rateType.name.lowercase(), + ).getOrThrow() + AggregatedSwapDataModel( + dataModel = QuoteModel( + toTokenAmount = createFromAmountWithOffset(response.toAmount, response.toDecimals), + allowanceContract = response.allowanceContract, ), ) - AggregatedSwapDataModel(dataModel = quotesConverter.convert(response)) - } catch (ex: OneIncResponseException) { - AggregatedSwapDataModel(null, mapErrors(ex.data.description)) + } catch (ex: Exception) { + AggregatedSwapDataModel(null, getDataError(ex)) } } } @@ -110,31 +203,37 @@ internal class SwapRepositoryImpl @Inject constructor( } } - override suspend fun prepareSwapTransaction( - networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - amount: String, - fromWalletAddress: String, - slippage: Int, + override suspend fun getExchangeData( + fromContractAddress: String, + fromNetwork: String, + toContractAddress: String, + toNetwork: String, + fromAmount: String, + fromDecimals: Int, + toDecimals: Int, + providerId: String, + rateType: RateType, + toAddress: String, ): AggregatedSwapDataModel { return withContext(coroutineDispatcher.io) { try { - val swapResponse = oneInchErrorsHandler.handleOneInchResponse( - getOneInchApi(networkId).swap( - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, - amount = amount, - fromAddress = fromWalletAddress, - slippage = slippage, - referrerAddress = configManager.config.swapReferrerAccount?.address, - fee = configManager.config.swapReferrerAccount?.fee, - ), + val response = tangemExpressApi.getExchangeData( + fromContractAddress = fromContractAddress, + fromNetwork = fromNetwork, + toContractAddress = toContractAddress, + toNetwork = toNetwork, + fromAmount = fromAmount, + fromDecimals = fromDecimals, + toDecimals = toDecimals, + providerId = providerId, + rateType = rateType.name.lowercase(), + toAddress = toAddress, + ).getOrThrow() + AggregatedSwapDataModel( + dataModel = expressDataConverter.convert(response), ) - - AggregatedSwapDataModel(swapConverter.convert(swapResponse)) - } catch (ex: OneIncResponseException) { - AggregatedSwapDataModel(null, mapErrors(ex.data.description)) + } catch (ex: Exception) { + AggregatedSwapDataModel(null, getDataError(ex)) } } } @@ -143,45 +242,13 @@ internal class SwapRepositoryImpl @Inject constructor( return configManager.config.swapReferrerAccount?.fee?.toDoubleOrNull() ?: 0.0 } - override suspend fun getCryptoCurrency( - userWallet: UserWallet, - currency: Currency, - network: Network, - ): CryptoCurrency? { - val blockchain = Blockchain.fromNetworkId(currency.networkId) ?: return null - val cryptoCurrencyFactory = CryptoCurrencyFactory() - return when (currency) { - is Currency.NativeToken -> { - cryptoCurrencyFactory.createCoin( - blockchain = blockchain, - extraDerivationPath = network.derivationPath.value, - derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, - ) - } - is Currency.NonNativeToken -> { - val sdkToken = SdkToken( - name = currency.name, - symbol = currency.symbol, - contractAddress = currency.contractAddress, - decimals = currency.decimalCount, - id = currency.id, - ) - cryptoCurrencyFactory.createToken( - sdkToken = sdkToken, - blockchain = blockchain, - extraDerivationPath = network.derivationPath.value, - derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider, - ) - } - } as CryptoCurrency - } - override suspend fun getAllowance( userWalletId: UserWalletId, networkId: String, derivationPath: String?, tokenDecimalCount: Int, tokenAddress: String, + spenderAddress: String, ): BigDecimal { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } val walletManager = walletManagersFacade.getOrCreateWalletManager( @@ -189,7 +256,6 @@ internal class SwapRepositoryImpl @Inject constructor( blockchain = blockchain, derivationPath = derivationPath, ) - val spenderAddress = addressForTrust(networkId) val result = (walletManager as? Approver)?.getAllowance( spenderAddress, @@ -210,8 +276,9 @@ internal class SwapRepositoryImpl @Inject constructor( userWalletId: UserWalletId, networkId: String, derivationPath: String?, - currency: Currency, + currency: CryptoCurrency, amount: BigDecimal?, + spenderAddress: String, ): String { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } @@ -220,7 +287,6 @@ internal class SwapRepositoryImpl @Inject constructor( blockchain = blockchain, derivationPath = derivationPath, ) - val spenderAddress = addressForTrust(networkId) return (walletManager as? Approver)?.getApproveData( spenderAddress, @@ -228,16 +294,16 @@ internal class SwapRepositoryImpl @Inject constructor( ) ?: error("Cannot cast to Approver") } - private fun convertToAmount(amount: BigDecimal, currency: Currency, blockchain: Blockchain): Amount { + private fun convertToAmount(amount: BigDecimal, currency: CryptoCurrency, blockchain: Blockchain): Amount { return when (currency) { - is Currency.NativeToken -> { + is CryptoCurrency.Token -> { Amount(value = amount, blockchain = blockchain) } - is Currency.NonNativeToken -> { + is CryptoCurrency.Coin -> { Amount( currencySymbol = currency.symbol, value = amount, - decimals = currency.decimalCount, + decimals = currency.decimals, ) } } @@ -247,6 +313,31 @@ internal class SwapRepositoryImpl @Inject constructor( return oneInchApiFactory.getApi(networkId) } + override fun getNativeTokenForNetwork(networkId: String): CryptoCurrency { + val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } + + return requireNotNull( + cryptoCurrencyFactory.createCoin( + blockchain = blockchain, + extraDerivationPath = null, + derivationStyleProvider = requireNotNull( + walletsStateHolder.userWalletsListManager + ?.selectedUserWalletSync + ?.scanResponse + ?.derivationStyleProvider, + ), + ), + ) + } + + private fun getDataError(ex: Exception): DataError { + return if (ex is ApiResponseError.HttpException) { + errorsDataConverter.convert(ex.errorBody ?: "") + } else { + DataError.UnknownError + } + } + companion object { // TODO("get this ids from blockchain enum later") private const val OPTIMISM_ID = "optimistic-ethereum" diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt new file mode 100644 index 0000000000..2068fc982a --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt @@ -0,0 +1,48 @@ +package com.tangem.feature.swap.converters + +import com.squareup.moshi.JsonAdapter +import com.tangem.datasource.api.express.models.response.ExpressErrorResponse +import com.tangem.feature.swap.domain.models.DataError +import com.tangem.feature.swap.domain.models.createFromAmountWithOffset +import com.tangem.utils.converter.Converter + +internal class ErrorsDataConverter( + private val jsonAdapter: JsonAdapter, +) : Converter { + + @Suppress("MagicNumber") + override fun convert(value: String): DataError { + try { + val error = jsonAdapter.fromJson(value)?.error ?: return DataError.UnknownError + + return when (error.code) { + 2010 -> DataError.BadRequest(code = error.code) + 2210 -> DataError.ExchangeProviderNotFoundError(code = error.code) + 2220 -> DataError.ExchangeProviderNotActiveError(code = error.code) + 2230 -> DataError.ExchangeProviderNotAvailableError(code = error.code) + 2240 -> DataError.ExchangeNotPossibleError(code = error.code) + 2250 -> DataError.ExchangeTooSmallAmountError( + code = error.code, + amount = createFromAmountWithOffset( + requireNotNull(error.value?.minAmount), + requireNotNull(error.value?.decimals), + ), + ) + 2260 -> DataError.ExchangeNotEnoughAllowanceError( + code = error.code, + currentAllowance = requireNotNull(error.value?.currentAllowance), + ) + 2270 -> DataError.ExchangeNotEnoughBalanceError(code = error.code) + 2280 -> DataError.ExchangeInvalidAddressError(code = error.code) + 2290 -> DataError.ExchangeInvalidFromDecimalsError( + code = error.code, + receivedFromDecimals = requireNotNull(error.value?.receivedFromDecimals), + expressFromDecimals = requireNotNull(error.value?.expressFromDecimals), + ) + else -> DataError.UnknownError + } + } catch (e: Exception) { + return DataError.UnknownError + } + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExchangeStatusConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExchangeStatusConverter.kt new file mode 100644 index 0000000000..f5901e8c69 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExchangeStatusConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.feature.swap.converters + +import com.tangem.datasource.api.express.models.response.ExchangeStatusResponse +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel +import com.tangem.utils.converter.Converter + +internal class ExchangeStatusConverter : Converter { + override fun convert(value: ExchangeStatusResponse): ExchangeStatusModel { + return ExchangeStatusModel( + providerId = value.providerId, + status = ExchangeStatus.values().firstOrNull { + it.name.lowercase() == value.externalStatus.name.lowercase() + }, + txId = value.externalTxId, + txUrl = value.externalTxUrl, + ) + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt new file mode 100644 index 0000000000..6746243002 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ExpressDataConverter.kt @@ -0,0 +1,40 @@ +package com.tangem.feature.swap.converters + +import com.tangem.datasource.api.express.models.response.ExchangeDataResponse +import com.tangem.datasource.api.express.models.response.TxType +import com.tangem.feature.swap.domain.models.createFromAmountWithOffset +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.utils.converter.Converter + +class ExpressDataConverter : Converter { + + override fun convert(value: ExchangeDataResponse): SwapDataModel { + return SwapDataModel( + toTokenAmount = createFromAmountWithOffset(value.toAmount, value.toDecimals), + transaction = convertTransaction(value), + ) + } + + private fun convertTransaction(transactionDto: ExchangeDataResponse): ExpressTransactionModel { + return if (transactionDto.txType == TxType.SWAP) { + ExpressTransactionModel.DEX( + fromAmount = createFromAmountWithOffset(transactionDto.fromAmount, transactionDto.fromDecimals), + toAmount = createFromAmountWithOffset(transactionDto.toAmount, transactionDto.toDecimals), + txId = transactionDto.txId, + txTo = transactionDto.txTo, + txFrom = requireNotNull(transactionDto.txFrom), + txData = requireNotNull(transactionDto.txData), + ) + } else { + ExpressTransactionModel.CEX( + fromAmount = createFromAmountWithOffset(transactionDto.fromAmount, transactionDto.fromDecimals), + toAmount = createFromAmountWithOffset(transactionDto.toAmount, transactionDto.toDecimals), + txId = transactionDto.txId, + txTo = transactionDto.txTo, + externalTxId = requireNotNull(transactionDto.externalTxId), + externalTxUrl = requireNotNull(transactionDto.externalTxUrl), + ) + } + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt new file mode 100644 index 0000000000..4ee7532cc7 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt @@ -0,0 +1,17 @@ +package com.tangem.feature.swap.converters + +import com.tangem.blockchain.common.Blockchain +import com.tangem.datasource.api.express.models.request.LeastTokenInfo +import com.tangem.domain.common.extensions.toNetworkId +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.converter.Converter + +class LeastTokenInfoConverter : Converter { + + override fun convert(value: CryptoCurrency): LeastTokenInfo { + return LeastTokenInfo( + contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = Blockchain.fromId(value.id.rawNetworkId).toNetworkId(), + ) + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/QuotesConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/QuotesConverter.kt deleted file mode 100644 index 4352341538..0000000000 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/QuotesConverter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.feature.swap.converters - -import com.tangem.datasource.api.oneinch.models.QuoteResponse -import com.tangem.feature.swap.domain.models.createFromAmountWithOffset -import com.tangem.feature.swap.domain.models.domain.QuoteModel -import com.tangem.utils.converter.Converter - -class QuotesConverter : Converter { - - override fun convert(value: QuoteResponse): QuoteModel { - return QuoteModel( - toTokenAmount = createFromAmountWithOffset(value.toTokenAmount, value.toToken.decimals), - ) - } -} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/RateTypeConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/RateTypeConverter.kt new file mode 100644 index 0000000000..0d25c4eca2 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/RateTypeConverter.kt @@ -0,0 +1,22 @@ +package com.tangem.feature.swap.converters + +import com.tangem.datasource.api.express.models.response.RateType +import com.tangem.utils.converter.TwoWayConverter +import com.tangem.feature.swap.domain.models.domain.RateType as RateTypeDomain + +class RateTypeConverter : TwoWayConverter { + + override fun convert(value: RateType): RateTypeDomain { + return when (value) { + RateType.FIXED -> RateTypeDomain.FIXED + RateType.FLOAT -> RateTypeDomain.FLOAT + } + } + + override fun convertBack(value: RateTypeDomain): RateType { + return when (value) { + RateTypeDomain.FIXED -> RateType.FIXED + RateTypeDomain.FLOAT -> RateType.FLOAT + } + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapConverter.kt deleted file mode 100644 index 3c8c1003ec..0000000000 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapConverter.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.tangem.feature.swap.converters - -import com.tangem.datasource.api.oneinch.models.SwapResponse -import com.tangem.datasource.api.oneinch.models.TransactionDto -import com.tangem.feature.swap.domain.models.createFromAmountWithOffset -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.domain.TransactionModel -import com.tangem.utils.converter.Converter - -class SwapConverter : Converter { - - override fun convert(value: SwapResponse): SwapDataModel { - return SwapDataModel( - toTokenAmount = createFromAmountWithOffset(value.toTokenAmount, value.toToken.decimals), - transaction = convertTransaction(value.transaction), - ) - } - - private fun convertTransaction(transactionDto: TransactionDto): TransactionModel { - return TransactionModel( - fromWalletAddress = transactionDto.fromAddress, - toWalletAddress = transactionDto.toAddress, - data = transactionDto.data, - value = transactionDto.value, - gasPrice = transactionDto.gasPrice, - gas = transactionDto.gas, - ) - } -} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt new file mode 100644 index 0000000000..ff86a7e049 --- /dev/null +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SwapPairInfoConverter.kt @@ -0,0 +1,53 @@ +package com.tangem.feature.swap.converters + +import com.tangem.datasource.api.express.models.response.* +import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo +import com.tangem.utils.converter.Converter +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType as ExchangeProviderTypeDomain +import com.tangem.feature.swap.domain.models.domain.SwapPairLeast as SwapPairDomain +import com.tangem.feature.swap.domain.models.domain.SwapProvider as SwapPairProviderDomain + +class SwapPairInfoConverter : Converter> { + + private val rateTypeConverter = RateTypeConverter() + + override fun convert(value: SwapPairsWithProviders): List { + val providersAdditionalMap = value.providers.associateBy { it.id } + return value.swapPair.map { pair -> + SwapPairDomain( + from = LeastTokenInfo( + contractAddress = pair.from.contractAddress, + network = pair.from.network, + ), + to = LeastTokenInfo( + contractAddress = pair.to.contractAddress, + network = pair.to.network, + ), + providers = pair.providers.mapNotNull { + convertProvider(it, providersAdditionalMap) + }, + ) + } + } + + private fun convertProvider( + swapPairProvider: SwapPairProvider, + providerAdditional: Map, + ): SwapPairProviderDomain? { + val additionalProvider = providerAdditional[swapPairProvider.providerId] ?: return null + return SwapPairProviderDomain( + providerId = swapPairProvider.providerId, + rateTypes = swapPairProvider.rateTypes.map { rateTypeConverter.convert(it) }, + name = additionalProvider.name, + type = convertExchangeType(additionalProvider.type), + imageLarge = additionalProvider.imageLargeUrl, + ) + } + + private fun convertExchangeType(type: ExchangeProviderType): ExchangeProviderTypeDomain { + return when (type) { + ExchangeProviderType.DEX -> ExchangeProviderTypeDomain.DEX + ExchangeProviderType.CEX -> ExchangeProviderTypeDomain.CEX + } + } +} \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index 81931ca111..b30b20f817 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -1,12 +1,20 @@ package com.tangem.feature.swap.di +import com.squareup.moshi.Moshi +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.response.ExpressErrorResponse import com.tangem.datasource.api.oneinch.OneInchApiFactory -import com.tangem.datasource.api.oneinch.OneInchErrorsHandler import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.config.ConfigManager +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.feature.swap.SwapRepositoryImpl +import com.tangem.feature.swap.converters.ErrorsDataConverter +import com.tangem.feature.swap.DefaultSwapTransactionRepository import com.tangem.feature.swap.domain.SwapRepository +import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -16,25 +24,44 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -class SwapDataModule { +internal class SwapDataModule { @Provides @Singleton - fun provideSwapRepository( + internal fun provideSwapRepository( tangemTechApi: TangemTechApi, + tangemExpressApi: TangemExpressApi, oneInchApiFactory: OneInchApiFactory, - oneInchErrorsHandler: OneInchErrorsHandler, coroutineDispatcher: CoroutineDispatcherProvider, configManager: ConfigManager, walletManagerFacade: WalletManagersFacade, + walletsStateHolder: WalletsStateHolder, + errorsDataConverter: ErrorsDataConverter, ): SwapRepository { return SwapRepositoryImpl( tangemTechApi = tangemTechApi, + tangemExpressApi = tangemExpressApi, oneInchApiFactory = oneInchApiFactory, - oneInchErrorsHandler = oneInchErrorsHandler, coroutineDispatcher = coroutineDispatcher, configManager = configManager, walletManagersFacade = walletManagerFacade, + walletsStateHolder = walletsStateHolder, + errorsDataConverter = errorsDataConverter, ) } + + @Provides + @Singleton + fun provideSwapTransactionRepository(appPreferencesStore: AppPreferencesStore): SwapTransactionRepository { + return DefaultSwapTransactionRepository( + appPreferencesStore = appPreferencesStore, + ) + } + + @Provides + @Singleton + internal fun provideErrorsConverter(@NetworkMoshi moshi: Moshi): ErrorsDataConverter { + val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java) + return ErrorsDataConverter(jsonAdapter) + } } \ No newline at end of file diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index b757775fd0..f3841b400d 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -23,9 +23,15 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.wallets) implementation(projects.domain.wallets.models) + implementation(projects.domain.transaction) + implementation(projects.domain.legacy) + implementation(projects.domain.demo) + implementation(projects.domain.card) + implementation(projects.domain.appCurrency.models) /** Core modules */ implementation(projects.core.utils) + implementation(projects.core.ui) /** Feature Apis */ implementation(projects.features.wallet.api) @@ -35,4 +41,6 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) implementation(deps.timber) + implementation(deps.tangem.blockchain) + implementation(deps.moshi) } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt index 9830aea157..8883e0f1a3 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractor.kt @@ -1,12 +1,9 @@ package com.tangem.feature.swap.domain -import com.tangem.feature.swap.domain.models.domain.Currency import com.tangem.feature.swap.domain.models.domain.NetworkInfo interface BlockchainInteractor { - fun getTokenDecimals(token: Currency): Int - /** * In app blockchain id, actual in blockchain sdk, not the same as networkId * diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt similarity index 67% rename from features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractorImpl.kt rename to features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt index 969b20c5b3..836f193fe3 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/BlockchainInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt @@ -1,11 +1,10 @@ package com.tangem.feature.swap.domain -import com.tangem.feature.swap.domain.models.domain.Currency import com.tangem.feature.swap.domain.models.domain.NetworkInfo import com.tangem.lib.crypto.TransactionManager import javax.inject.Inject -internal class BlockchainInteractorImpl @Inject constructor( +internal class DefaultBlockchainInteractor @Inject constructor( private val transactionManager: TransactionManager, ) : BlockchainInteractor { @@ -22,12 +21,4 @@ internal class BlockchainInteractorImpl @Inject constructor( override fun getExplorerTransactionLink(networkId: String, txAddress: String): String { return transactionManager.getExplorerTransactionLink(networkId, txAddress) } - - override fun getTokenDecimals(token: Currency): Int { - return if (token is Currency.NonNativeToken) { - token.decimalCount - } else { - transactionManager.getNativeTokenDecimals(token.networkId) - } - } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt new file mode 100644 index 0000000000..42e6f2331b --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt @@ -0,0 +1,33 @@ +package com.tangem.feature.swap.domain + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress +import java.math.BigDecimal + +internal class DefaultInitialToCurrencyResolver( + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val swapTransactionRepository: SwapTransactionRepository, +) : InitialToCurrencyResolver { + + override suspend fun tryGetFromCache( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + ): CryptoCurrencyStatus? { + val selectedId = getSelectedWalletSyncUseCase().getOrNull() ?: return null + val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(selectedId.walletId) ?: return null + + return if (id != initialCryptoCurrency.id.value) { + state.toGroup.available.find { it.currencyStatus.currency.id.value == id }?.currencyStatus + } else { + null + } + } + + override fun tryGetWithMaxAmount(state: TokensDataStateExpress): CryptoCurrencyStatus? { + return state.toGroup.available.maxByOrNull { + it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO + }?.currencyStatus + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt new file mode 100644 index 0000000000..da0b0f1b37 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt @@ -0,0 +1,15 @@ +package com.tangem.feature.swap.domain + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress + +interface InitialToCurrencyResolver { + + suspend fun tryGetFromCache( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + ): CryptoCurrencyStatus? + + fun tryGetWithMaxAmount(state: TokensDataStateExpress): CryptoCurrencyStatus? +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 56c1766da8..d36610f673 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -1,42 +1,18 @@ package com.tangem.feature.swap.domain +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.Network +import com.tangem.domain.wallets.models.UserWallet import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.PermissionOptions +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* -import java.math.BigDecimal interface SwapInteractor { - fun initDerivationPathAndNetwork(derivationPath: String?, network: Network?) + suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress - /** - * Init tokens to swap, load tokens list available to swap for given network - * - * @param initialCurrency currency which to swap or receive - * @return [TokensDataState] that contains info about all available to swap tokens for networkId - * and preselected tokens which initially select to swap - */ - suspend fun initTokensToSwap(initialCurrency: Currency): TokensDataState - - /** - * On search token, locally search tokens in previously loaded list to swap - * searching in names and symbols - * - * @param networkId networkId for tokens - * @param searchQuery string query for search - * @return [FoundTokensState] that contains list of tokens matching condition query - */ - suspend fun searchTokens(networkId: String, searchQuery: String): FoundTokensState - - /** - * Find specific token by id, null if not found - * - * @param id token id - * @return [Currency] or null - */ - fun findTokenById(id: String): Currency? + fun initDerivationPathAndNetwork(derivationPath: String?, network: Network) /** * Gives permission to swap, this starts scan card process @@ -61,11 +37,12 @@ interface SwapInteractor { @Throws(IllegalStateException::class) suspend fun findBestQuote( networkId: String, - fromToken: Currency, - toToken: Currency, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + providers: List, amountToSwap: String, selectedFee: FeeType = FeeType.NORMAL, - ): SwapState + ): Map /** * Starts swap transaction, perform sign transaction @@ -81,30 +58,39 @@ interface SwapInteractor { @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun onSwap( + swapProvider: SwapProvider, networkId: String, - swapStateData: SwapStateData, - currencyToSend: Currency, - currencyToGet: Currency, + swapData: SwapDataModel?, + currencyToSend: CryptoCurrencyStatus, + currencyToGet: CryptoCurrencyStatus, amountToSwap: String, + includeFeeInAmount: IncludeFeeInAmount, fee: TxFee, ): TxState + suspend fun updateQuotesStateWithSelectedFee( + state: SwapState.QuotesLoadedState, + selectedFee: FeeType, + fromToken: CryptoCurrencyStatus, + amountToSwap: String, + networkId: String, + ): SwapState.QuotesLoadedState + /** * Returns token in wallet balance * - * @param networkId * @param token */ - fun getTokenBalance(networkId: String, token: Currency): SwapAmount + fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount fun isAvailableToSwap(networkId: String): Boolean - fun getSwapAmountForToken(amount: String, token: Currency): SwapAmount + fun getSelectedWallet(): UserWallet? - suspend fun checkFeeIsEnough( - fee: BigDecimal?, - spendAmount: SwapAmount, - networkId: String, - fromToken: Currency, - ): Boolean + suspend fun selectInitialCurrencyToSwap( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + ): CryptoCurrencyStatus? + + fun getNativeToken(networkId: String): CryptoCurrency } \ No newline at end of file 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 4e6dcfa4cf..5a843b643a 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 @@ -1,46 +1,61 @@ package com.tangem.feature.swap.domain -import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase +import arrow.core.getOrElse +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.Network -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.domain.tokens.utils.convertToAmount +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase -import com.tangem.feature.swap.domain.cache.SwapDataCache import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel import com.tangem.feature.swap.domain.models.domain.* -import com.tangem.feature.swap.domain.models.domain.Currency import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.lib.crypto.TransactionManager import com.tangem.lib.crypto.UserWalletManager import com.tangem.lib.crypto.models.* import com.tangem.lib.crypto.models.transactions.SendTxResult +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.toFiatString +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.firstOrNull import timber.log.Timber import java.math.BigDecimal import java.math.RoundingMode import javax.inject.Inject -import com.tangem.lib.crypto.models.Currency as LibCurrency @Suppress("LargeClass", "LongParameterList") internal class SwapInteractorImpl @Inject constructor( private val transactionManager: TransactionManager, private val userWalletManager: UserWalletManager, private val repository: SwapRepository, - private val cache: SwapDataCache, private val allowPermissionsHandler: AllowPermissionsHandler, - private val currenciesRepository: CurrenciesRepository, - private val networksRepository: NetworksRepository, - private val walletFeatureToggles: WalletFeatureToggles, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + private val walletManagersFacade: WalletManagersFacade, + private val sendTransactionUseCase: SendTransactionUseCase, + private val quotesRepository: QuotesRepository, + private val dispatcher: CoroutineDispatcherProvider, + private val swapTransactionRepository: SwapTransactionRepository, + private val initialToCurrencyResolver: InitialToCurrencyResolver, ) : SwapInteractor { - // TODO: Move to DI - private val addCryptoCurrenciesUseCase by lazy(LazyThreadSafetyMode.NONE) { - AddCryptoCurrenciesUseCase(currenciesRepository, networksRepository) + private val getFeeUseCase by lazy(LazyThreadSafetyMode.NONE) { + GetFeeUseCase(walletManagersFacade, dispatcher) } private val swapCurrencyConverter = SwapCurrencyConverter() @@ -48,96 +63,138 @@ internal class SwapInteractorImpl @Inject constructor( private var derivationPath: String? = null private var network: Network? = null - override fun initDerivationPathAndNetwork(derivationPath: String?, network: Network?) { + override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { + val selectedWallet = getSelectedWalletSyncUseCase().fold( + ifLeft = { null }, + ifRight = { it }, + ) + + requireNotNull(selectedWallet) { "No selected wallet" } + + val walletCurrencyStatuses = getMultiCryptoCurrencyStatusUseCase(selectedWallet.walletId) + .getOrElse { emptyList() } + + val walletCurrencyStatusesExceptInitial = walletCurrencyStatuses + .filter { + val currencyFilter = it.currency.network.backendId != currency.network.backendId || + it.currency.getContractAddress() != currency.getContractAddress() + val statusFilter = it.value is CryptoCurrencyStatus.Loaded + statusFilter && currencyFilter + } + + if (walletCurrencyStatusesExceptInitial.isEmpty()) { + return TokensDataStateExpress( + fromGroup = CurrenciesGroup(emptyList(), emptyList()), + toGroup = CurrenciesGroup(emptyList(), emptyList()), + ) + } + + val pairsLeast = getPairs( + initialCurrency = LeastTokenInfo( + contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", + network = currency.network.backendId, + ), + currenciesList = walletCurrencyStatusesExceptInitial.map { it.currency }, + ) + + return TokensDataStateExpress( + fromGroup = getToCurrenciesGroup( + currency = currency, + leastPairs = pairsLeast, + cryptoCurrenciesList = walletCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.from }, + tokenInfoForAvailable = { it.to }, + ), + toGroup = getToCurrenciesGroup( + currency = currency, + leastPairs = pairsLeast, + cryptoCurrenciesList = walletCurrencyStatusesExceptInitial, + tokenInfoForFilter = { it.to }, + tokenInfoForAvailable = { it.from }, + ), + ) + } + + override fun getSelectedWallet(): UserWallet? { + return getSelectedWalletSyncUseCase().getOrNull() + } + + private fun getToCurrenciesGroup( + currency: CryptoCurrency, + leastPairs: List, + cryptoCurrenciesList: List, + tokenInfoForFilter: (SwapPairLeast) -> LeastTokenInfo, + tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, + ): CurrenciesGroup { + val filteredPairs = leastPairs.filter { + tokenInfoForFilter(it).contractAddress == currency.getContractAddress() && + tokenInfoForFilter(it).network == currency.network.backendId + } + + val availableCryptoCurrencies = cryptoCurrenciesList.mapNotNull { cryptoCurrencyStatus -> + val providers = findProvidersForPair(cryptoCurrencyStatus, filteredPairs, tokenInfoForAvailable) + if (providers != null) { + CryptoCurrencySwapInfo(cryptoCurrencyStatus, providers) + } else { + null + } + } + + val unavailableCryptoCurrencies = cryptoCurrenciesList - availableCryptoCurrencies + .map { it.currencyStatus } + .toSet() + + return CurrenciesGroup( + available = availableCryptoCurrencies, + unavailable = unavailableCryptoCurrencies.map { CryptoCurrencySwapInfo(it, emptyList()) }, + ) + } + + private fun findProvidersForPair( + cryptoCurrencyStatuses: CryptoCurrencyStatus, + swapPairsLeastList: List, + tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, + ): List? { + return swapPairsLeastList.firstNotNullOfOrNull { + val listTokenInfo = tokenInfoForAvailable(it) + if (cryptoCurrencyStatuses.currency.network.backendId == listTokenInfo.network && + cryptoCurrencyStatuses.currency.getContractAddress() == listTokenInfo.contractAddress + ) { + it.providers + } else { + null + } + } + } + + private fun CryptoCurrency.getContractAddress(): String { + return when (this) { + is CryptoCurrency.Token -> this.contractAddress + is CryptoCurrency.Coin -> "0" + } + } + + private suspend fun getPairs( + initialCurrency: LeastTokenInfo, + currenciesList: List, + ): List { + return repository.getPairs(initialCurrency, currenciesList) + } + + @Deprecated("used in old swap mechanism") + override fun initDerivationPathAndNetwork(derivationPath: String?, network: Network) { this.derivationPath = derivationPath this.network = network } - override suspend fun initTokensToSwap(initialCurrency: Currency): TokensDataState { - // TODO: refactor this function - val networkId = initialCurrency.networkId - val availableTokens = cache.getAvailableTokens(networkId) - val allLoadedTokens = availableTokens.ifEmpty { - val tokens = repository.getExchangeableTokens(networkId) - cache.cacheAvailableToSwapTokens(networkId, tokens) - tokens - }.filter { it.symbol != initialCurrency.symbol } - - // replace tokens in wallet tokens list with loaded same - val loadedOnWalletsMap = mutableSetOf() - val tokensInWallet = userWalletManager.getUserTokens( - networkId = networkId, - derivationPath = derivationPath, - isExcludeCustom = true, - ) - .map { token -> - val contractAddress = (token as? LibCurrency.NonNativeToken)?.contractAddress - allLoadedTokens.firstOrNull { - if (it is Currency.NonNativeToken) { - it.symbol == token.symbol && it.contractAddress == contractAddress - } else { - it.symbol == token.symbol - } - }?.let { - loadedOnWalletsMap.add(it.symbol) - it - } ?: swapCurrencyConverter.convertBack(token) - } - .filter { it.symbol != initialCurrency.symbol && allLoadedTokens.contains(it) } - val loadedTokens = allLoadedTokens - .filter { - !loadedOnWalletsMap.contains(it.symbol) - } - val tokensBalance = userWalletManager.getCurrentWalletTokensBalance(networkId, emptyList(), derivationPath) - .mapValues { SwapAmount(it.value.value, it.value.decimals) } - val appCurrency = userWalletManager.getUserAppCurrency() - val rates = repository.getRates(appCurrency.code, tokensInWallet.map { it.id }) - cache.cacheBalances(networkId, derivationPath, tokensBalance) - cache.cacheLoadedTokens(loadedTokens.map { TokenWithBalance(it) }) - cache.cacheInWalletTokens(getTokensWithBalance(tokensInWallet, tokensBalance, rates, appCurrency)) - return TokensDataState( - preselectTokens = PreselectTokens( - fromToken = initialCurrency, - toToken = selectToToken(initialCurrency, tokensInWallet, loadedTokens), - ), - foundTokensState = FoundTokensState( - tokensInWallet = cache.getInWalletTokens(), - loadedTokens = cache.getLoadedTokens(), - ), - ) - } - - override suspend fun searchTokens(networkId: String, searchQuery: String): FoundTokensState { - val searchQueryLowerCase = searchQuery.lowercase() - val tokensInWallet = cache.getInWalletTokens() - .filter { - it.token.name.lowercase().contains(searchQueryLowerCase) || - it.token.symbol.lowercase().contains(searchQueryLowerCase) - } - val loadedTokens = cache.getLoadedTokens() - .filter { - it.token.name.lowercase().contains(searchQueryLowerCase) || - it.token.symbol.lowercase().contains(searchQueryLowerCase) - } - return FoundTokensState( - tokensInWallet = tokensInWallet, - loadedTokens = loadedTokens, - ) - } - - override fun findTokenById(id: String): Currency? { - val tokensInWallet = cache.getInWalletTokens() - val loadedTokens = cache.getLoadedTokens() - return tokensInWallet.firstOrNull { it.token.id == id }?.token - ?: loadedTokens.firstOrNull { it.token.id == id }?.token - } - + @Deprecated("used in old swap mechanism") override suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): TxState { val dataToSign = if (permissionOptions.approveType == SwapApproveType.UNLIMITED) { getApproveData( networkId = networkId, derivationPath = derivationPath, fromToken = permissionOptions.fromToken, + spenderAddress = permissionOptions.spenderAddress, ) } else { permissionOptions.approveData.approveData @@ -160,7 +217,10 @@ internal class SwapInteractorImpl @Inject constructor( return when (result) { is SendTxResult.Success -> { allowPermissionsHandler.addAddressToInProgress(permissionOptions.forTokenContractAddress) - TxState.TxSent(txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath) ?: "") + TxState.TxSent( + txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), + timestamp = System.currentTimeMillis(), + ) } SendTxResult.UserCancelledError -> TxState.UserCancelled is SendTxResult.BlockchainSdkError -> TxState.BlockchainError @@ -170,56 +230,208 @@ internal class SwapInteractorImpl @Inject constructor( } } + @Deprecated("used in old swap mechanism") override suspend fun findBestQuote( networkId: String, - fromToken: Currency, - toToken: Currency, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + providers: List, amountToSwap: String, selectedFee: FeeType, - ): SwapState { - syncWalletBalanceForTokens(networkId, listOf(fromToken, toToken)) - val amountDecimal = toBigDecimalOrNull(amountToSwap) - if (amountDecimal == null || amountDecimal.signum() == 0) { - return createEmptyAmountState(networkId, fromToken, toToken) + ): Map { + return providers.map { provider -> + val amountDecimal = toBigDecimalOrNull(amountToSwap) + if (amountDecimal == null || amountDecimal.signum() == 0) { + return providers.associateWith { + createEmptyAmountState(fromToken, toToken) + } + } + val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency)) + val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) + + when (provider.type) { + ExchangeProviderType.DEX -> { + manageDex( + networkId = networkId, + fromToken = fromToken, + toToken = toToken, + provider = provider, + selectedFee = selectedFee, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + ) + } + ExchangeProviderType.CEX -> { + manageCex( + networkId = networkId, + fromToken = fromToken, + toToken = toToken, + provider = provider, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + selectedFee = selectedFee, + ) + } + } + }.toMap() + } + + private suspend fun manageDex( + networkId: String, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + provider: SwapProvider, + selectedFee: FeeType, + amount: SwapAmount, + isBalanceWithoutFeeEnough: Boolean, + ): Pair { + val quotes = repository.findBestQuote( + fromContractAddress = fromToken.currency.getContractAddress(), + fromNetwork = fromToken.currency.network.backendId, + toContractAddress = toToken.currency.getContractAddress(), + toNetwork = toToken.currency.network.backendId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = toToken.currency.decimals, + providerId = provider.providerId, + rateType = RateType.FLOAT, + ) + + val fromTokenAddress = getTokenAddress(fromToken.currency) + val isAllowedToSpend = if (quotes.dataModel != null) { + quotes.dataModel.allowanceContract?.let { + isAllowedToSpend(networkId, fromToken.currency, amount, it) + } ?: true + } else { + false } - val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken)) - val fromTokenAddress = getTokenAddress(fromToken) - val toTokenAddress = getTokenAddress(toToken) - val isAllowedToSpend = isAllowedToSpend(networkId, fromToken, amount) + if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) transactionManager.updateWalletManager(networkId, derivationPath) } - val isBalanceWithoutFeeEnough = isBalanceEnough(networkId, fromToken, amount, null) return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { - loadSwapData( + provider to loadDexSwapData( + provider = provider, networkId = networkId, - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, fromToken = fromToken, toToken = toToken, amount = amount, selectedFee = selectedFee, ) } else { - loadQuoteData( - networkId = networkId, - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, + provider to getQuotesState( + exchangeProviderType = ExchangeProviderType.DEX, + quoteDataModel = quotes, amount = amount, fromToken = fromToken, toToken = toToken, + networkId = networkId, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + txFee = TxFeeState.Empty, + includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex ) } } - override suspend fun onSwap( + private suspend fun manageCex( networkId: String, - swapStateData: SwapStateData, - currencyToSend: Currency, - currencyToGet: Currency, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + provider: SwapProvider, + amount: SwapAmount, + isBalanceWithoutFeeEnough: Boolean, + selectedFee: FeeType, + ): Pair { + return provider to loadCexQuoteData( + exchangeProviderType = ExchangeProviderType.CEX, + networkId = networkId, + amount = amount, + fromTokenStatus = fromToken, + toTokenStatus = toToken, + isAllowedToSpend = true, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + provider = provider, + selectedFee = selectedFee, + ) + } + + override suspend fun onSwap( + swapProvider: SwapProvider, + networkId: String, + swapData: SwapDataModel?, + currencyToSend: CryptoCurrencyStatus, + currencyToGet: CryptoCurrencyStatus, + amountToSwap: String, + includeFeeInAmount: IncludeFeeInAmount, + fee: TxFee, + ): TxState { + return when (swapProvider.type) { + ExchangeProviderType.CEX -> { + val amountDecimal = toBigDecimalOrNull(amountToSwap) + val amount = SwapAmount(requireNotNull(amountDecimal), getTokenDecimals(currencyToSend.currency)) + val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { + includeFeeInAmount.amountSubtractFee + } else { + amount + } + onSwapCex( + currencyToSend = currencyToSend, + currencyToGet = currencyToGet, + amount = amountToSwapWithFee, + txFee = fee, + swapProvider = swapProvider, + userWalletId = requireNotNull(getSelectedWallet()).walletId, + ) + } + ExchangeProviderType.DEX -> { + onSwapDex( + networkId = networkId, + swapData = requireNotNull(swapData), + currencyToSend = currencyToSend.currency, + currencyToGet = currencyToGet.currency, + amountToSwap = amountToSwap, + fee = fee, + ) + } + } + } + + override suspend fun updateQuotesStateWithSelectedFee( + state: SwapState.QuotesLoadedState, + selectedFee: FeeType, + fromToken: CryptoCurrencyStatus, + amountToSwap: String, + networkId: String, + ): SwapState.QuotesLoadedState { + val amountDecimal = toBigDecimalOrNull(amountToSwap) + if (amountDecimal == null || amountDecimal.signum() == 0) { + return state + } + val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency)) + val includeFeeInAmount = getIncludeFeeInAmount( + networkId = networkId, + txFee = state.txFee, + amount = amount, + fromToken = fromToken.currency, + selectedFee = selectedFee, + ) + return state.copy( + permissionState = PermissionDataState.Empty, + preparedSwapConfigState = state.preparedSwapConfigState.copy( + isBalanceEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, + isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, + includeFeeInAmount = includeFeeInAmount, + ), + ) + } + + private suspend fun onSwapDex( + networkId: String, + swapData: SwapDataModel, + currencyToSend: CryptoCurrency, + currencyToGet: CryptoCurrency, amountToSwap: String, fee: TxFee, ): TxState { @@ -232,8 +444,8 @@ internal class SwapInteractorImpl @Inject constructor( currencyToSend = swapCurrencyConverter.convert(currencyToSend), feeAmount = fee.feeValue, gasLimit = fee.gasLimit, - destinationAddress = swapStateData.swapModel.transaction.toWalletAddress, - dataToSign = swapStateData.swapModel.transaction.data, + destinationAddress = swapData.transaction.txTo, + dataToSign = (swapData.transaction as ExpressTransactionModel.DEX).txData, ), isSwap = true, derivationPath = derivationPath, @@ -244,21 +456,18 @@ internal class SwapInteractorImpl @Inject constructor( ) return when (result) { is SendTxResult.Success -> { - if (walletFeatureToggles.isRedesignedScreenEnabled) { - onSuccessNewFlow(currencyToGet) - } else { - onSuccessLegacyFlow(currencyToGet) - } + storeLastCryptoCurrencyId(currencyToGet) TxState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( amount, currencyToSend.symbol, ), toAmount = amountFormatter.formatSwapAmountToUI( - swapStateData.swapModel.toTokenAmount, + swapData.toTokenAmount, currencyToGet.symbol, ), - txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath) ?: "", + txAddress = userWalletManager.getLastTransactionHash(networkId, derivationPath).orEmpty(), + timestamp = System.currentTimeMillis(), ) } SendTxResult.UserCancelledError -> TxState.UserCancelled @@ -269,112 +478,186 @@ internal class SwapInteractorImpl @Inject constructor( } } - override fun getTokenBalance(networkId: String, token: Currency): SwapAmount { - return cache.getBalanceForToken( - networkId = networkId, - derivationPath = derivationPath, - symbol = token.symbol, - ) ?: SwapAmount(BigDecimal.ZERO, getTokenDecimals(token)) - } + private suspend fun onSwapCex( + currencyToSend: CryptoCurrencyStatus, + currencyToGet: CryptoCurrencyStatus, + amount: SwapAmount, + txFee: TxFee, + swapProvider: SwapProvider, + userWalletId: UserWalletId, + ): TxState { + val exchangeData = repository.getExchangeData( + fromContractAddress = currencyToSend.currency.getContractAddress(), + fromNetwork = currencyToSend.currency.network.backendId, + toContractAddress = currencyToGet.currency.getContractAddress(), + toNetwork = currencyToGet.currency.network.backendId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = currencyToGet.currency.decimals, + providerId = swapProvider.providerId, + rateType = RateType.FLOAT, + toAddress = currencyToGet.value.networkAddress?.defaultAddress?.value ?: "", + ) - override fun isAvailableToSwap(networkId: String): Boolean { - return ONE_INCH_SUPPORTED_NETWORKS.contains(networkId) - } + val txData = walletManagersFacade.createTransaction( + amount = amount.value.convertToAmount(currencyToSend.currency), + fee = getFeeForTransaction(txFee), + memo = null, + destination = (exchangeData.dataModel?.transaction as ExpressTransactionModel.CEX).txTo, + userWalletId = userWalletId, + network = currencyToSend.currency.network, + ) - override fun getSwapAmountForToken(amount: String, token: Currency): SwapAmount { - val amountDecimal = requireNotNull(toBigDecimalOrNull(amount)) { "wrong amount format" } - return SwapAmount(amountDecimal, getTokenDecimals(token)) - } + val result = sendTransactionUseCase( + requireNotNull(txData), + userWallet = requireNotNull(getSelectedWallet()), + network = currencyToSend.currency.network, + ) - private suspend fun onSuccessLegacyFlow(currency: Currency) { - userWalletManager.addToken(swapCurrencyConverter.convert(currency), derivationPath) - userWalletManager.refreshWallet() - } + val externalUrl = (exchangeData.dataModel.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl - private suspend fun onSuccessNewFlow(currency: Currency) { - val network = network ?: return - getSelectedWalletSyncUseCase().fold( - ifRight = { userWallet -> - getAndAddCryptoCurrency(userWallet, currency, network) - }, + return result.fold( ifLeft = { - Timber.e("Swap Error on getSelectedWalletUseCase") + when (it) { + SendTransactionError.UserCancelledError -> TxState.UserCancelled + is SendTransactionError.BlockchainSdkError -> TxState.BlockchainError + is SendTransactionError.TangemSdkError -> TxState.TangemSdkError + is SendTransactionError.NetworkError -> TxState.NetworkError + else -> TxState.UnknownError + } + }, + ifRight = { + val timestamp = System.currentTimeMillis() + storeSwapTransaction( + currencyToSend = currencyToSend, + currencyToGet = currencyToGet, + amount = amount, + swapProvider = swapProvider, + swapDataModel = exchangeData.dataModel, + timestamp = timestamp, + ) + storeLastCryptoCurrencyId(currencyToGet.currency) + TxState.TxSent( + fromAmount = amountFormatter.formatSwapAmountToUI( + amount, + currencyToSend.currency.symbol, + ), + toAmount = amountFormatter.formatSwapAmountToUI( + exchangeData.dataModel.toTokenAmount, + currencyToGet.currency.symbol, + ), + txAddress = userWalletManager.getLastTransactionHash( + currencyToSend.currency.network.backendId, + derivationPath, + ).orEmpty(), + txExternalUrl = externalUrl, + timestamp = timestamp, + ) }, ) } - private suspend fun getAndAddCryptoCurrency(userWallet: UserWallet, currency: Currency, network: Network) { - repository.getCryptoCurrency(userWallet, currency, network)?.let { cryptoCurrency -> - addCryptoCurrenciesUseCase(userWallet.walletId, cryptoCurrency) + private fun getFeeForTransaction(fee: TxFee): Fee { + val feeAmountValue = fee.feeValue + val feeAmount = Amount( + value = fee.feeValue, + currencySymbol = fee.cryptoSymbol, + decimals = fee.decimals, + type = AmountType.Coin, + ) + + return if (fee.gasLimit != 0) { + Fee.Ethereum( + amount = feeAmount, + gasLimit = fee.gasLimit.toBigInteger(), + gasPrice = (feeAmountValue / fee.gasLimit.toBigDecimal()).toBigInteger(), + ) + } else { + Fee.Common(feeAmount) } } + private suspend fun storeSwapTransaction( + currencyToSend: CryptoCurrencyStatus, + currencyToGet: CryptoCurrencyStatus, + amount: SwapAmount, + swapProvider: SwapProvider, + swapDataModel: SwapDataModel, + timestamp: Long, + ) { + swapTransactionRepository.storeTransaction( + userWalletId = UserWalletId(userWalletManager.getWalletId()), + fromCryptoCurrencyId = currencyToSend.currency.id, + toCryptoCurrencyId = currencyToGet.currency.id, + transaction = SavedSwapTransactionModel( + txId = swapDataModel.transaction.txId, + provider = swapProvider, + timestamp = timestamp, + fromCryptoAmount = amount.value, + toCryptoAmount = swapDataModel.toTokenAmount.value, + ), + ) + } + + private suspend fun storeLastCryptoCurrencyId(cryptoCurrency: CryptoCurrency) { + swapTransactionRepository.storeLastSwappedCryptoCurrencyId( + UserWalletId(userWalletManager.getWalletId()), + cryptoCurrency.id, + ) + } + + @Deprecated("used in old swap mechanism") + override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount { + return SwapAmount(token.value.amount ?: BigDecimal.ZERO, getTokenDecimals(token.currency)) + } + + @Deprecated("used in old swap mechanism") + override fun isAvailableToSwap(networkId: String): Boolean { + return ONE_INCH_SUPPORTED_NETWORKS.contains(networkId) + } + + override suspend fun selectInitialCurrencyToSwap( + initialCryptoCurrency: CryptoCurrency, + state: TokensDataStateExpress, + ): CryptoCurrencyStatus? { + return initialToCurrencyResolver.tryGetFromCache(initialCryptoCurrency, state) + ?: initialToCurrencyResolver.tryGetWithMaxAmount(state) + ?: state.toGroup.available.firstOrNull()?.currencyStatus + } + + override fun getNativeToken(networkId: String): CryptoCurrency { + return repository.getNativeTokenForNetwork(networkId) + } + + @Deprecated("used in old swap mechanism") private fun getTangemFee(): Double { return repository.getTangemFee() } - private fun getTokenDecimals(token: Currency): Int { - return if (token is Currency.NonNativeToken) { - token.decimalCount + private fun getTokenDecimals(token: CryptoCurrency): Int { + return if (token is CryptoCurrency.Token) { + token.decimals } else { - transactionManager.getNativeTokenDecimals(token.networkId) + transactionManager.getNativeTokenDecimals(token.network.backendId) } } - private fun selectToToken( - initialToken: Currency, - tokensInWallet: List, - loadedTokens: List, - ): Currency { - val toToken = if (tokensInWallet.isNotEmpty()) { - tokensInWallet.firstOrNull { it.symbol != initialToken.symbol } - ?: loadedTokens.first { it.symbol != initialToken.symbol } - } else { - val findUsdt = loadedTokens.firstOrNull { it.symbol == USDT_SYMBOL && it.symbol != initialToken.symbol } - if (findUsdt == null) { - val findUsdc = loadedTokens.firstOrNull { it.symbol == USDC_SYMBOL && it.symbol != initialToken.symbol } - findUsdc ?: loadedTokens.first { it.symbol != initialToken.symbol } - } else { - findUsdt - } - } - return toToken - } - - private fun getTokensWithBalance( - tokens: List, - balances: Map, - rates: Map, - appCurrency: ProxyFiatCurrency, - ): List { - return tokens.map { - val balance = balances[it.symbol] - TokenWithBalance( - token = it, - tokenBalanceData = TokenBalanceData( - amount = balance?.let { amount -> - amountFormatter.formatSwapAmountToUI(amount, it.symbol) - }, - amountEquivalent = balance?.value?.toFiatString( - rateValue = rates[it.id]?.toBigDecimal() ?: BigDecimal.ZERO, - fiatCurrencyName = appCurrency.symbol, - formatWithSpaces = true, - ), - ), - ) - } - } - - private suspend fun isAllowedToSpend(networkId: String, fromToken: Currency, amount: SwapAmount): Boolean { - if (fromToken is Currency.NativeToken) return true + private suspend fun isAllowedToSpend( + networkId: String, + fromToken: CryptoCurrency, + amount: SwapAmount, + spenderAddress: String, + ): Boolean { + if (fromToken is CryptoCurrency.Coin) return true return getSelectedWalletSyncUseCase().fold( ifRight = { userWallet -> val allowance = repository.getAllowance( - userWallet.walletId, - networkId, - derivationPath, - getTokenDecimals(fromToken), - getTokenAddress(fromToken), + userWalletId = userWallet.walletId, + networkId = networkId, + derivationPath = derivationPath, + tokenDecimalCount = getTokenDecimals(fromToken), + tokenAddress = getTokenAddress(fromToken), + spenderAddress = spenderAddress, ) allowance >= amount.value }, @@ -385,13 +668,13 @@ internal class SwapInteractorImpl @Inject constructor( ) } - private fun createEmptyAmountState(networkId: String, fromToken: Currency, toToken: Currency): SwapState { + private fun createEmptyAmountState(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): SwapState { val appCurrency = userWalletManager.getUserAppCurrency() - val fromTokenBalance = cache.getBalanceForToken(networkId, derivationPath, fromToken.symbol) - val toTokenBalance = cache.getBalanceForToken(networkId, derivationPath, toToken.symbol) + val fromTokenBalance = getTokenBalance(fromToken) + val toTokenBalance = getTokenBalance(toToken) return SwapState.EmptyAmountState( - fromTokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }.orEmpty(), - toTokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") }.orEmpty(), + fromTokenWalletBalance = amountFormatter.formatSwapAmountToUI(fromTokenBalance, ""), + toTokenWalletBalance = amountFormatter.formatSwapAmountToUI(toTokenBalance, ""), zeroAmountEquivalent = BigDecimal.ZERO.toFiatString( rateValue = BigDecimal.ONE, fiatCurrencyName = appCurrency.symbol, @@ -404,57 +687,182 @@ internal class SwapInteractorImpl @Inject constructor( * Load quote data calls only if spend is not allowed for token contract address */ @Suppress("LongParameterList") - private suspend fun loadQuoteData( + private suspend fun loadCexQuoteData( + exchangeProviderType: ExchangeProviderType, networkId: String, - fromTokenAddress: String, - toTokenAddress: String, amount: SwapAmount, - fromToken: Currency, - toToken: Currency, + fromTokenStatus: CryptoCurrencyStatus, + toTokenStatus: CryptoCurrencyStatus, + provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, + selectedFee: FeeType, ): SwapState { - repository.findBestQuote( - networkId = networkId, - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, - amount = amount.toStringWithRightOffset(), - ).let { quotes -> - val quoteDataModel = quotes.dataModel - if (quoteDataModel != null) { - val swapState = updateBalances( - networkId = networkId, - fromToken = fromToken, - toToken = toToken, - fromTokenAmount = amount, - toTokenAmount = quoteDataModel.toTokenAmount, - swapStateData = null, - ) - val quotesState = updatePermissionState( - networkId = networkId, - fromToken = fromToken, - swapAmount = amount, - quotesLoadedState = swapState, - ) - return quotesState.copy( - preparedSwapConfigState = quotesState.preparedSwapConfigState.copy( + val fromToken = fromTokenStatus.currency + val toToken = toTokenStatus.currency + return coroutineScope { + val txFee = if (provider.type == ExchangeProviderType.CEX) { + getFeeForCex(amount, fromTokenStatus, networkId) + } else { + TxFeeState.Empty + } + + val includeFeeInAmount = getIncludeFeeInAmount( + networkId = networkId, + txFee = txFee, + amount = amount, + fromToken = fromToken, + selectedFee = selectedFee, + ) + val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { + includeFeeInAmount.amountSubtractFee + } else { + amount + } + + val quotes = repository.findBestQuote( + fromContractAddress = fromToken.getContractAddress(), + fromNetwork = fromToken.network.backendId, + toContractAddress = toToken.getContractAddress(), + toNetwork = toToken.network.backendId, + fromAmount = amountToRequest.toStringWithRightOffset(), + fromDecimals = amount.decimals, + providerId = provider.providerId, + toDecimals = toToken.decimals, + rateType = RateType.FLOAT, + ) + + getQuotesState( + exchangeProviderType = exchangeProviderType, + quoteDataModel = quotes, + amount = amount, + fromToken = fromTokenStatus, + toToken = toTokenStatus, + networkId = networkId, + isAllowedToSpend = isAllowedToSpend, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + txFee = txFee, + includeFeeInAmount = includeFeeInAmount, + ) + } + } + + private suspend fun getQuotesState( + exchangeProviderType: ExchangeProviderType, + quoteDataModel: AggregatedSwapDataModel, + amount: SwapAmount, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + networkId: String, + isAllowedToSpend: Boolean, + isBalanceWithoutFeeEnough: Boolean, + txFee: TxFeeState, + includeFeeInAmount: IncludeFeeInAmount, + ): SwapState { + val quoteModel = quoteDataModel.dataModel + if (quoteModel != null) { + val swapState = updateBalances( + networkId = networkId, + fromTokenStatus = fromToken, + toTokenStatus = toToken, + fromTokenAmount = amount, + toTokenAmount = quoteModel.toTokenAmount, + swapData = null, + txFeeState = txFee, + ) + + return when (exchangeProviderType) { + ExchangeProviderType.DEX -> { + val state = updatePermissionState( + networkId = networkId, + fromTokenStatus = fromToken, + swapAmount = amount, + quotesLoadedState = swapState, isAllowedToSpend = isAllowedToSpend, - isBalanceEnough = isBalanceWithoutFeeEnough, + spenderAddress = quoteModel.allowanceContract, + ) + state.copy( + preparedSwapConfigState = state.preparedSwapConfigState.copy( + isAllowedToSpend = isAllowedToSpend, + isBalanceEnough = isBalanceWithoutFeeEnough, + ), + ) + } + ExchangeProviderType.CEX -> { + swapState.copy( + permissionState = PermissionDataState.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, + isAllowedToSpend = isAllowedToSpend, + isBalanceEnough = isBalanceWithoutFeeEnough, + includeFeeInAmount = includeFeeInAmount, + ), + ) + } + } + } else { + val rates = getQuotes(fromToken.currency.id) + val fromTokenSwapInfo = TokenSwapInfo( + tokenAmount = amount, + amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) + ?: BigDecimal.ZERO, + cryptoCurrencyStatus = fromToken, + ) + return SwapState.SwapError(fromTokenSwapInfo, quoteDataModel.error) + } + } + + private suspend fun getIncludeFeeInAmount( + networkId: String, + txFee: TxFeeState, + amount: SwapAmount, + fromToken: CryptoCurrency, + selectedFee: FeeType, + ): IncludeFeeInAmount { + if (fromToken is CryptoCurrency.Token) { + return IncludeFeeInAmount.Excluded + } + + val tokenForFeeBalance = + userWalletManager.getNativeTokenBalance(networkId, derivationPath) ?: ProxyAmount.empty() + + if (amount.value > tokenForFeeBalance.value) { + return IncludeFeeInAmount.BalanceNotEnough + } + val feeValue = when (txFee) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> if (selectedFee == FeeType.NORMAL) { + txFee.normalFee.feeValue + } else { + txFee.priorityFee.feeValue + } + is TxFeeState.SingleFeeState -> txFee.fee.feeValue + } + + val amountWithFee = amount.value + feeValue + return if (amountWithFee < tokenForFeeBalance.value) { + IncludeFeeInAmount.Excluded + } else { + if (feeValue < amount.value) { + IncludeFeeInAmount.Included( + SwapAmount( + tokenForFeeBalance.value - feeValue, + transactionManager.getNativeTokenDecimals(networkId), ), ) } else { - return SwapState.SwapError(quotes.error) + IncludeFeeInAmount.BalanceNotEnough } } } private suspend fun getFormattedFiatFees(networkId: String, vararg fees: BigDecimal): List { val appCurrency = userWalletManager.getUserAppCurrency() - val nativeToken = userWalletManager.getNativeTokenForNetwork(networkId) - val rates = repository.getRates(appCurrency.code, listOf(nativeToken.id)) - return rates[nativeToken.id]?.toBigDecimal()?.let { rate -> + val nativeToken = repository.getNativeTokenForNetwork(networkId) + val rates = getQuotes(nativeToken.id) + return rates[nativeToken.id]?.fiatRate?.let { rate -> fees.map { fee -> - " (${fee.toFiatString(rate, appCurrency.symbol, true)})" + fee.toFiatString(rate, appCurrency.symbol, true) } }.orEmpty() } @@ -463,57 +871,57 @@ internal class SwapInteractorImpl @Inject constructor( * Load swap data calls only if spend is allowed for token contract address */ @Suppress("LongParameterList") - private suspend fun loadSwapData( + private suspend fun loadDexSwapData( + provider: SwapProvider, networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - fromToken: Currency, - toToken: Currency, + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, amount: SwapAmount, selectedFee: FeeType, ): SwapState { - repository.prepareSwapTransaction( - networkId = networkId, - fromTokenAddress = fromTokenAddress, - toTokenAddress = toTokenAddress, - amount = amount.toStringWithRightOffset(), - slippage = DEFAULT_SLIPPAGE, - fromWalletAddress = getWalletAddress(networkId), + repository.getExchangeData( + fromContractAddress = fromToken.currency.getContractAddress(), + fromNetwork = fromToken.currency.network.backendId, + toContractAddress = toToken.currency.getContractAddress(), + toNetwork = toToken.currency.network.backendId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = toToken.currency.decimals, + providerId = provider.providerId, + rateType = RateType.FLOAT, + toAddress = toToken.value.networkAddress?.defaultAddress?.value ?: "", ).let { val swapData = it.dataModel if (swapData != null) { val feeData = transactionManager.getFee( networkId = networkId, amountToSend = amount.value, - currencyToSend = swapCurrencyConverter.convert(fromToken), - destinationAddress = swapData.transaction.toWalletAddress, + currencyToSend = swapCurrencyConverter.convert(fromToken.currency), + destinationAddress = swapData.transaction.txTo, increaseBy = INCREASE_GAS_LIMIT_BY, - data = swapData.transaction.data, + data = (swapData.transaction as ExpressTransactionModel.DEX).txData, derivationPath = derivationPath, ) - val txFeeState = proxyFeesToFeeState(networkId, feeData) - val feeByPriority = when (selectedFee) { - FeeType.NORMAL -> txFeeState.normalFee.feeValue - FeeType.PRIORITY -> txFeeState.priorityFee.feeValue + val txFeeState = when (feeData) { + is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId) + is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId) } - val isBalanceIncludeFeeEnough = - isBalanceEnough(networkId, fromToken, amount, feeByPriority) + val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState) + val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeByPriority) val isFeeEnough = checkFeeIsEnough( fee = feeByPriority, spendAmount = amount, networkId = networkId, - fromToken = fromToken, + fromToken = fromToken.currency, ) val swapState = updateBalances( networkId = networkId, - fromToken = fromToken, - toToken = toToken, + fromTokenStatus = fromToken, + toTokenStatus = toToken, fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, - swapStateData = SwapStateData( - fee = txFeeState, - swapModel = swapData, - ), + swapData = swapData, + txFeeState = txFeeState, ) return swapState.copy( permissionState = PermissionDataState.Empty, @@ -521,10 +929,21 @@ internal class SwapInteractorImpl @Inject constructor( isAllowedToSpend = true, isBalanceEnough = isBalanceIncludeFeeEnough, isFeeEnough = isFeeEnough, + includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex ), ) } else { - return SwapState.SwapError(it.error) + val rates = getQuotes(fromToken.currency.id) + val fromTokenSwapInfo = TokenSwapInfo( + tokenAmount = amount, + amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) + ?: BigDecimal.ZERO, + cryptoCurrencyStatus = fromToken, + ) + return SwapState.SwapError( + fromTokenSwapInfo, + it.error, + ) } } } @@ -532,61 +951,85 @@ internal class SwapInteractorImpl @Inject constructor( @Suppress("LongParameterList") private suspend fun updateBalances( networkId: String, - fromToken: Currency, - toToken: Currency, + fromTokenStatus: CryptoCurrencyStatus, + toTokenStatus: CryptoCurrencyStatus, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, - swapStateData: SwapStateData?, + swapData: SwapDataModel?, + txFeeState: TxFeeState, ): SwapState.QuotesLoadedState { - val appCurrency = userWalletManager.getUserAppCurrency() - val nativeToken = userWalletManager.getNativeTokenForNetwork(networkId) - val rates = repository.getRates(appCurrency.code, listOf(fromToken.id, toToken.id, nativeToken.id)) - val fromTokenBalance = cache.getBalanceForToken(networkId, derivationPath, fromToken.symbol) - val toTokenBalance = cache.getBalanceForToken(networkId, derivationPath, toToken.symbol) + val fromToken = fromTokenStatus.currency + val toToken = toTokenStatus.currency + val nativeToken = repository.getNativeTokenForNetwork(networkId) + + val rates = getQuotes(fromToken.id, toToken.id, nativeToken.id) return SwapState.QuotesLoadedState( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, - coinId = fromToken.id, - tokenWalletBalance = fromTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") } - ?: ZERO_BALANCE, - tokenFiatBalance = fromTokenAmount.value.toFiatString( - rateValue = rates[fromToken.id]?.toBigDecimal() ?: BigDecimal.ZERO, - fiatCurrencyName = appCurrency.symbol, - formatWithSpaces = true, - ), + cryptoCurrencyStatus = fromTokenStatus, + amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) + ?: BigDecimal.ZERO, ), toTokenInfo = TokenSwapInfo( tokenAmount = toTokenAmount, - coinId = toToken.id, - tokenWalletBalance = toTokenBalance?.let { amountFormatter.formatSwapAmountToUI(it, "") } - ?: ZERO_BALANCE, - tokenFiatBalance = toTokenAmount.value.toFiatString( - rateValue = rates[toToken.id]?.toBigDecimal() ?: BigDecimal.ZERO, - fiatCurrencyName = appCurrency.symbol, - formatWithSpaces = true, - ), + cryptoCurrencyStatus = toTokenStatus, + amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) + ?: BigDecimal.ZERO, ), priceImpact = calculatePriceImpact( fromTokenAmount = fromTokenAmount.value, - fromRate = rates[fromToken.id] ?: 0.0, + fromRate = rates[fromToken.id]?.fiatRate?.toDouble() ?: 0.0, toTokenAmount = toTokenAmount.value, - toRate = rates[toToken.id] ?: 0.0, + toRate = rates[toToken.id]?.fiatRate?.toDouble() ?: 0.0, ), networkCurrency = userWalletManager.getNetworkCurrency(networkId), - swapDataModel = swapStateData, + swapDataModel = swapData, tangemFee = getTangemFee(), + txFee = txFeeState, ) } - @Suppress("LongParameterList") + private suspend fun getFeeForCex( + amount: SwapAmount, + fromToken: CryptoCurrencyStatus, + networkId: String, + ): TxFeeState { + getSelectedWalletSyncUseCase().getOrNull()?.walletId?.let { userWalletId -> + val txFeeResult = getFeeUseCase( + amount = amount.value, + destination = fromToken.value.networkAddress?.defaultAddress?.value ?: "", + userWalletId = userWalletId, + cryptoCurrency = fromToken.currency, + ).firstOrNull() + return txFeeResult?.fold( + ifLeft = { + TxFeeState.Empty + }, + ifRight = { txFee -> + txFee.toTxFeeState(networkId) + }, + ) ?: TxFeeState.Empty + } + return TxFeeState.Empty + } + + @Suppress("LongParameterList", "LongMethod") private suspend fun updatePermissionState( networkId: String, - fromToken: Currency, + fromTokenStatus: CryptoCurrencyStatus, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, + spenderAddress: String?, + isAllowedToSpend: Boolean, ): SwapState.QuotesLoadedState { + val fromToken = fromTokenStatus.currency + if (isAllowedToSpend) { + return quotesLoadedState.copy( + permissionState = PermissionDataState.Empty, + ) + } // if token balance ZERO not show permission state to avoid user to spend money for fee - val isTokenZeroBalance = getTokenBalance(networkId, fromToken).value.signum() == 0 + val isTokenZeroBalance = getTokenBalance(fromTokenStatus).value.signum() == 0 if (isTokenZeroBalance) { return quotesLoadedState.copy( permissionState = PermissionDataState.Empty, @@ -603,19 +1046,35 @@ internal class SwapInteractorImpl @Inject constructor( derivationPath = derivationPath, fromToken = fromToken, swapAmount = swapAmount, + spenderAddress = requireNotNull(spenderAddress) { "Spender address is null" }, ) - val feeData = transactionManager.getFee( - networkId = networkId, - amountToSend = BigDecimal.ZERO, - currencyToSend = userWalletManager.getNativeTokenForNetwork(networkId), - destinationAddress = getTokenAddress(fromToken), - increaseBy = INCREASE_GAS_LIMIT_BY, - data = transactionData, - derivationPath = derivationPath, - ) - val feeState = proxyFeesToFeeState(networkId, feeData) + val feeData = try { + transactionManager.getFee( + networkId = networkId, + amountToSend = BigDecimal.ZERO, + currencyToSend = swapCurrencyConverter.convert(repository.getNativeTokenForNetwork(networkId)), + destinationAddress = fromToken.getContractAddress(), + increaseBy = INCREASE_GAS_LIMIT_BY, + data = transactionData, + derivationPath = derivationPath, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to get fee") + null + } + val feeState = feeData?.let { + when (feeData) { + is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId) + is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId) + } + } ?: TxFeeState.Empty + val fee = when (feeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue + is TxFeeState.SingleFeeState -> feeState.fee.feeValue + } val isFeeEnough = checkFeeIsEnough( - fee = feeData.normalFee.fee.value, + fee = fee, spendAmount = SwapAmount.zeroSwapAmount(), networkId = networkId, fromToken = fromToken, @@ -630,6 +1089,7 @@ internal class SwapInteractorImpl @Inject constructor( fee = feeState, approveData = transactionData, fromTokenAmount = swapAmount, + spenderAddress = spenderAddress, ), ), preparedSwapConfigState = quotesLoadedState.preparedSwapConfigState.copy( @@ -638,47 +1098,32 @@ internal class SwapInteractorImpl @Inject constructor( ) } - private suspend fun syncWalletBalanceForTokens(networkId: String, tokens: List) { - val tokensToSync = tokens.filter { cache.getBalanceForToken(networkId, derivationPath, it.symbol) == null } - if (tokensToSync.isNotEmpty()) { - val tokensBalance = - userWalletManager.getCurrentWalletTokensBalance( - networkId = networkId, - extraTokens = tokensToSync.map { swapCurrencyConverter.convert(it) }, - derivationPath = derivationPath, - ) - cache.cacheBalances( - networkId = networkId, - derivationPath = derivationPath, - balances = tokensBalance.mapValues { SwapAmount(it.value.value, it.value.decimals) }, - ) - } - } - - private suspend fun proxyFeesToFeeState(networkId: String, proxyFees: ProxyFees): TxFeeState { - val normalFeeValue = proxyFees.minFee.fee.value // in swap for normal use min fee - val normalFeeGas = proxyFees.minFee.gasLimit.toInt() - val priorityFeeValue = proxyFees.normalFee.fee.value // in swap for priority use normal fee - val priorityFeeGas = proxyFees.normalFee.gasLimit.toInt() + private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(networkId: String): TxFeeState { + val normalFeeValue = this.minFee.fee.value // in swap for normal use min fee + val normalFeeGas = this.minFee.gasLimit.toInt() + val priorityFeeValue = this.normalFee.fee.value // in swap for priority use normal fee + val priorityFeeGas = this.normalFee.gasLimit.toInt() val feesFiat = getFormattedFiatFees(networkId, normalFeeValue, priorityFeeValue) val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" } val priorityFiatFee = requireNotNull(feesFiat.getOrNull(1)) { "feesFiat item 1 couldn't be null" } + val networkCurrency = userWalletManager.getNetworkCurrency(networkId) + val decimals = transactionManager.getNativeTokenDecimals(networkId) val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeValue, - decimals = transactionManager.getNativeTokenDecimals(networkId), - currency = userWalletManager.getNetworkCurrency(networkId), + decimals = decimals, ) val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = priorityFeeValue, - decimals = transactionManager.getNativeTokenDecimals(networkId), - currency = userWalletManager.getNetworkCurrency(networkId), + decimals = decimals, ) - return TxFeeState( + return TxFeeState.MultipleFeeState( normalFee = TxFee( feeValue = normalFeeValue, gasLimit = normalFeeGas, feeFiatFormatted = normalFiatFee, feeCryptoFormatted = normalCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, feeType = FeeType.NORMAL, ), priorityFee = TxFee( @@ -686,19 +1131,119 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = priorityFeeGas, feeFiatFormatted = priorityFiatFee, feeCryptoFormatted = priorityCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, feeType = FeeType.PRIORITY, ), ) } - private fun isBalanceEnough( - networkId: String, - fromToken: Currency, - amount: SwapAmount, - fee: BigDecimal?, - ): Boolean { - val tokenBalance = getTokenBalance(networkId, fromToken).value - return if (fromToken is Currency.NonNativeToken) { + private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState(networkId: String): TxFeeState { + val normalFeeValue = this.singleFee.fee.value + val normalFeeGas = this.singleFee.gasLimit.toInt() + val networkCurrency = userWalletManager.getNetworkCurrency(networkId) + val feesFiat = getFormattedFiatFees(networkId, normalFeeValue) + val normalFiatFee = requireNotNull(feesFiat.getOrNull(0)) { "feesFiat item 0 couldn't be null" } + val decimals = transactionManager.getNativeTokenDecimals(networkId) + val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( + amount = normalFeeValue, + decimals = decimals, + ) + return TxFeeState.SingleFeeState( + fee = TxFee( + feeValue = normalFeeValue, + gasLimit = normalFeeGas, + feeFiatFormatted = normalFiatFee, + feeCryptoFormatted = normalCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, + feeType = FeeType.NORMAL, + ), + ) + } + + private suspend fun TransactionFee.toTxFeeState(networkId: String): TxFeeState { + val networkCurrency = userWalletManager.getNetworkCurrency(networkId) + val decimals = transactionManager.getNativeTokenDecimals(networkId) + return when (this) { + is TransactionFee.Choosable -> { + val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO + val feePriority = this.priority.amount.value ?: BigDecimal.ZERO + val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0] + val priorityFiatValue = getFormattedFiatFees(networkId, feePriority)[0] + + val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( + amount = feeNormal, + decimals = decimals, + ) + val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( + amount = feePriority, + decimals = decimals, + ) + TxFeeState.MultipleFeeState( + normalFee = TxFee( + feeValue = feeNormal, + gasLimit = this.normal.getGasLimit(), + feeFiatFormatted = normalFiatValue, + feeCryptoFormatted = normalCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, + feeType = FeeType.NORMAL, + ), + priorityFee = TxFee( + feeValue = feePriority, + gasLimit = this.priority.getGasLimit(), + feeFiatFormatted = priorityFiatValue, + feeCryptoFormatted = priorityCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, + feeType = FeeType.PRIORITY, + ), + ) + } + is TransactionFee.Single -> { + val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO + val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0] + val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( + amount = feeNormal, + decimals = transactionManager.getNativeTokenDecimals(networkId), + ) + TxFeeState.SingleFeeState( + fee = TxFee( + feeValue = this.normal.amount.value ?: BigDecimal.ZERO, + gasLimit = this.normal.getGasLimit(), + feeFiatFormatted = normalFiatValue, + feeCryptoFormatted = normalCryptoFee, + decimals = decimals, + cryptoSymbol = networkCurrency, + feeType = FeeType.NORMAL, + ), + ) + } + } + } + + private fun Fee.getGasLimit(): Int { + return when (this) { + is Fee.Common -> 0 + is Fee.Ethereum -> this.gasLimit.toInt() + } + } + + private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): BigDecimal { + return when (txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.SingleFeeState -> txFeeState.fee.feeValue + is TxFeeState.MultipleFeeState -> when (feeType) { + FeeType.NORMAL -> txFeeState.normalFee.feeValue + FeeType.PRIORITY -> txFeeState.priorityFee.feeValue + } + } + } + + private fun isBalanceEnough(fromToken: CryptoCurrencyStatus, amount: SwapAmount, fee: BigDecimal?): Boolean { + val tokenBalance = getTokenBalance(fromToken).value + return if (fromToken.currency is CryptoCurrency.Token) { tokenBalance >= amount.value } else { tokenBalance > amount.value.plus(fee ?: BigDecimal.ZERO) @@ -709,22 +1254,22 @@ internal class SwapInteractorImpl @Inject constructor( return userWalletManager.getWalletAddress(networkId, derivationPath) } - private fun getTokenAddress(currency: Currency): String { + private fun getTokenAddress(currency: CryptoCurrency): String { return when (currency) { - is Currency.NativeToken -> { - DEFAULT_BLOCKCHAIN_INCH_ADDRESS + is CryptoCurrency.Coin -> { + "0" } - is Currency.NonNativeToken -> { + is CryptoCurrency.Token -> { currency.contractAddress } } } - override suspend fun checkFeeIsEnough( + private suspend fun checkFeeIsEnough( fee: BigDecimal?, spendAmount: SwapAmount, networkId: String, - fromToken: Currency, + fromToken: CryptoCurrency, ): Boolean { if (fee == null) { return false @@ -732,12 +1277,12 @@ internal class SwapInteractorImpl @Inject constructor( val nativeTokenBalance = userWalletManager.getNativeTokenBalance(networkId, derivationPath) val percentsToFeeIncrease = BigDecimal.ONE return when (fromToken) { - is Currency.NativeToken -> { + is CryptoCurrency.Coin -> { nativeTokenBalance?.let { balance -> return balance.value.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease) } ?: false } - is Currency.NonNativeToken -> { + is CryptoCurrency.Token -> { nativeTokenBalance?.let { balance -> return balance.value > fee.multiply(percentsToFeeIncrease) } ?: false @@ -763,8 +1308,9 @@ internal class SwapInteractorImpl @Inject constructor( private suspend fun getApproveData( networkId: String, derivationPath: String?, - fromToken: Currency, + fromToken: CryptoCurrency, swapAmount: SwapAmount? = null, + spenderAddress: String, ): String { return getSelectedWalletSyncUseCase().fold( ifRight = { userWallet -> @@ -774,6 +1320,7 @@ internal class SwapInteractorImpl @Inject constructor( derivationPath = derivationPath, currency = fromToken, amount = swapAmount?.value, + spenderAddress = spenderAddress, ) }, ifLeft = { @@ -783,16 +1330,17 @@ internal class SwapInteractorImpl @Inject constructor( ) } - companion object { - private const val DEFAULT_SLIPPAGE = 2 - private const val ZERO_BALANCE = "0" - private const val DEFAULT_BLOCKCHAIN_INCH_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" + private suspend fun getQuotes(vararg ids: CryptoCurrency.ID): Map { + val set = quotesRepository.getQuotesSync(ids.toSet(), false) + return ids + .mapNotNull { id -> set.find { it.rawCurrencyId == id.rawCurrencyId }?.let { id to it } } + .toMap() + } + + companion object { @Suppress("UnusedPrivateMember") - private const val INCREASE_FEE_TO_CHECK_ENOUGH_PERCENT = 1.0 // if need to increase fee when check isEnough private const val INCREASE_GAS_LIMIT_BY = 112 // 12% - private const val USDT_SYMBOL = "USDT" - private const val USDC_SYMBOL = "USDC" private const val INFINITY_SYMBOL = "∞" private val ONE_INCH_SUPPORTED_NETWORKS = listOf( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt index b303325c8c..9f90d4dc3d 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapRepository.kt @@ -1,26 +1,33 @@ package com.tangem.feature.swap.domain +import arrow.core.Either import com.tangem.domain.tokens.model.CryptoCurrency -import com.tangem.domain.tokens.model.Network -import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.swap.domain.models.data.AggregatedSwapDataModel -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.QuoteModel -import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.* import java.math.BigDecimal interface SwapRepository { + suspend fun getPairs(initialCurrency: LeastTokenInfo, currencyList: List): List + suspend fun getRates(currencyId: String, tokenIds: List): Map suspend fun getExchangeableTokens(networkId: String): List + suspend fun getExchangeStatus(txId: String): Either + + @Suppress("LongParameterList") suspend fun findBestQuote( - networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - amount: String, + fromContractAddress: String, + fromNetwork: String, + toContractAddress: String, + toNetwork: String, + fromAmount: String, + fromDecimals: Int, + toDecimals: Int, + providerId: String, + rateType: RateType, ): AggregatedSwapDataModel /** @@ -30,24 +37,13 @@ interface SwapRepository { */ suspend fun addressForTrust(networkId: String): String - @Suppress("LongParameterList") - suspend fun prepareSwapTransaction( - networkId: String, - fromTokenAddress: String, - toTokenAddress: String, - amount: String, - fromWalletAddress: String, - slippage: Int, - ): AggregatedSwapDataModel - /** * Returns a tangem fee for swap in percents * Example: 0.35% */ fun getTangemFee(): Double - suspend fun getCryptoCurrency(userWallet: UserWallet, currency: Currency, network: Network): CryptoCurrency? - + @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun getAllowance( userWalletId: UserWalletId, @@ -55,14 +51,33 @@ interface SwapRepository { derivationPath: String?, tokenDecimalCount: Int, tokenAddress: String, + spenderAddress: String, ): BigDecimal + @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun getApproveData( userWalletId: UserWalletId, networkId: String, derivationPath: String?, - currency: Currency, + currency: CryptoCurrency, amount: BigDecimal?, + spenderAddress: String, ): String + + @Suppress("LongParameterList") + suspend fun getExchangeData( + fromContractAddress: String, + fromNetwork: String, + toContractAddress: String, + toNetwork: String, + fromAmount: String, + fromDecimals: Int, + toDecimals: Int, + providerId: String, + rateType: RateType, + toAddress: String, + ): AggregatedSwapDataModel + + fun getNativeTokenForNetwork(networkId: String): CryptoCurrency } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt new file mode 100644 index 0000000000..2db58ee01d --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -0,0 +1,33 @@ +package com.tangem.feature.swap.domain + +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel +import kotlinx.coroutines.flow.Flow + +interface SwapTransactionRepository { + + suspend fun storeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + transaction: SavedSwapTransactionModel, + ) + + fun getTransactions( + userWalletId: UserWalletId, + cryptoCurrencyId: CryptoCurrency.ID, + ): Flow?> + + suspend fun removeTransaction( + userWalletId: UserWalletId, + fromCryptoCurrencyId: CryptoCurrency.ID, + toCryptoCurrencyId: CryptoCurrency.ID, + txId: String, + ) + + suspend fun storeLastSwappedCryptoCurrencyId(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID) + + suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt deleted file mode 100644 index 7f4bf919cc..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCache.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.feature.swap.domain.cache - -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.ui.TokenWithBalance -import java.math.BigDecimal - -interface SwapDataCache { - - fun cacheAvailableToSwapTokens(networkId: String, tokens: List) - fun cacheInWalletTokens(tokens: List) - fun cacheLoadedTokens(tokens: List) - fun cacheBalances(networkId: String, derivationPath: String?, balances: Map) - fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String) - fun getAvailableTokens(networkId: String): List - fun getInWalletTokens(): List - fun getLoadedTokens(): List - fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount? - fun getLastFeeForNetwork(networkId: String): BigDecimal? -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt deleted file mode 100644 index 4379d70d9f..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/cache/SwapDataCacheImpl.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.tangem.feature.swap.domain.cache - -import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.ui.TokenWithBalance -import java.math.BigDecimal - -class SwapDataCacheImpl : SwapDataCache { - - private val availableTokensForNetwork: MutableMap> = mutableMapOf() - private val feesForNetworks: MutableMap = mutableMapOf() - private val tokensBalances: MutableMap> = mutableMapOf() - private val lastInWalletTokens = mutableListOf() - private val lastLoadedTokens = mutableListOf() - - override fun cacheLastFeeForNetwork(fee: BigDecimal, networkId: String) { - feesForNetworks[networkId] = fee - } - - override fun cacheInWalletTokens(tokens: List) { - lastInWalletTokens.clear() - lastInWalletTokens.addAll(tokens) - } - - override fun cacheLoadedTokens(tokens: List) { - lastLoadedTokens.clear() - lastLoadedTokens.addAll(tokens) - } - - override fun getInWalletTokens(): List { - return lastInWalletTokens - } - - override fun getLoadedTokens(): List { - return lastLoadedTokens - } - - override fun getBalanceForToken(networkId: String, derivationPath: String?, symbol: String): SwapAmount? { - return tokensBalances[createKeyFrom(networkId, derivationPath)]?.get(symbol) - } - - override fun cacheBalances(networkId: String, derivationPath: String?, balances: Map) { - tokensBalances[createKeyFrom(networkId, derivationPath)] = balances - } - - override fun cacheAvailableToSwapTokens(networkId: String, tokens: List) { - availableTokensForNetwork[networkId] = tokens - } - - override fun getLastFeeForNetwork(networkId: String): BigDecimal? { - return feesForNetworks[networkId] - } - - override fun getAvailableTokens(networkId: String): List { - return availableTokensForNetwork.getOrElse(networkId) { emptyList() } - } - - private fun createKeyFrom(networkId: String, derivationPath: String?): String { - return "$networkId;$derivationPath" - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/converters/SwapCurrencyConverter.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/converters/SwapCurrencyConverter.kt index d766f5029a..2cf345a0bb 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/converters/SwapCurrencyConverter.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/converters/SwapCurrencyConverter.kt @@ -1,54 +1,29 @@ package com.tangem.feature.swap.domain.converters -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.utils.converter.TwoWayConverter -import com.tangem.lib.crypto.models.Currency as CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.lib.crypto.models.Currency +import com.tangem.utils.converter.Converter -class SwapCurrencyConverter : TwoWayConverter { +class SwapCurrencyConverter : Converter { - override fun convert(value: Currency): CryptoCurrency { + override fun convert(value: CryptoCurrency): Currency { return when (value) { - is Currency.NonNativeToken -> { - CryptoCurrency.NonNativeToken( - id = value.id, - name = value.name, - symbol = value.symbol, - networkId = value.networkId, - contractAddress = value.contractAddress, - decimalCount = value.decimalCount, - ) - } - is Currency.NativeToken -> { - CryptoCurrency.NativeToken( - id = value.id, - name = value.name, - symbol = value.symbol, - networkId = value.networkId, - ) - } - } - } - - override fun convertBack(value: CryptoCurrency): Currency { - return when (value) { - is CryptoCurrency.NonNativeToken -> { + is CryptoCurrency.Token -> { Currency.NonNativeToken( - id = value.id, + id = value.id.value, name = value.name, symbol = value.symbol, - networkId = value.networkId, + networkId = value.network.id.value, contractAddress = value.contractAddress, - decimalCount = value.decimalCount, - logoUrl = "", + decimalCount = value.decimals, ) } - is CryptoCurrency.NativeToken -> { + is CryptoCurrency.Coin -> { Currency.NativeToken( - id = value.id, + id = value.id.value, name = value.name, symbol = value.symbol, - networkId = value.networkId, - logoUrl = "", + networkId = value.network.id.value, ) } } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 7a94dfb303..f6bf9093c9 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -1,14 +1,21 @@ package com.tangem.feature.swap.domain.di +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.demo.DemoConfig +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.tokens.GetCardTokensListUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase 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.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.WalletsStateHolder import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.feature.swap.domain.* -import com.tangem.feature.swap.domain.cache.SwapDataCacheImpl -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.lib.crypto.TransactionManager import com.tangem.lib.crypto.UserWalletManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -26,28 +33,35 @@ class SwapDomainModule { swapRepository: SwapRepository, userWalletManager: UserWalletManager, transactionManager: TransactionManager, - currenciesRepository: CurrenciesRepository, - networksRepository: NetworksRepository, - walletFeatureToggles: WalletFeatureToggles, @SwapScope getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + @SwapScope sendTransactionUseCase: SendTransactionUseCase, + quotesRepository: QuotesRepository, + swapTransactionRepository: SwapTransactionRepository, + walletManagersFacade: WalletManagersFacade, + coroutineDispatcherProvider: CoroutineDispatcherProvider, + initialToCurrencyResolver: InitialToCurrencyResolver, ): SwapInteractor { return SwapInteractorImpl( transactionManager = transactionManager, userWalletManager = userWalletManager, repository = swapRepository, - cache = SwapDataCacheImpl(), allowPermissionsHandler = AllowPermissionsHandlerImpl(), - currenciesRepository = currenciesRepository, - networksRepository = networksRepository, - walletFeatureToggles = walletFeatureToggles, getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + getMultiCryptoCurrencyStatusUseCase = getCryptoCurrencyStatusUseCase, + sendTransactionUseCase = sendTransactionUseCase, + quotesRepository = quotesRepository, + walletManagersFacade = walletManagersFacade, + dispatcher = coroutineDispatcherProvider, + swapTransactionRepository = swapTransactionRepository, + initialToCurrencyResolver = initialToCurrencyResolver, ) } @Provides @Singleton fun provideBlockchainInteractor(transactionManager: TransactionManager): BlockchainInteractor { - return BlockchainInteractorImpl( + return DefaultBlockchainInteractor( transactionManager = transactionManager, ) } @@ -58,6 +72,72 @@ class SwapDomainModule { fun providesGetSelectedWalletUseCase(walletsStateHolder: WalletsStateHolder): GetSelectedWalletSyncUseCase { return GetSelectedWalletSyncUseCase(walletsStateHolder = walletsStateHolder) } + + @Provides + @Singleton + fun providesGetCryptoCurrencyStatusUseCase( + currenciesRepository: CurrenciesRepository, + quotesRepository: QuotesRepository, + networksRepository: NetworksRepository, + dispatchers: CoroutineDispatcherProvider, + ): GetCryptoCurrencyStatusesSyncUseCase { + return GetCryptoCurrencyStatusesSyncUseCase( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + dispatchers = dispatchers, + ) + } + + @SwapScope + @Provides + @Singleton + fun providesGetCardTokensListUseCase( + currenciesRepository: CurrenciesRepository, + quotesRepository: QuotesRepository, + networksRepository: NetworksRepository, + dispatchers: CoroutineDispatcherProvider, + ): GetCardTokensListUseCase { + return GetCardTokensListUseCase( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + dispatchers = dispatchers, + ) + } + + @SwapScope + @Provides + fun provideDemoCardUseCase(): IsDemoCardUseCase { + return IsDemoCardUseCase(config = DemoConfig()) + } + + @SwapScope + @Provides + @Singleton + fun provideSendTransactionUseCase( + @SwapScope isDemoCardUseCase: IsDemoCardUseCase, + walletManagersFacade: WalletManagersFacade, + cardSdkConfigRepository: CardSdkConfigRepository, + ): SendTransactionUseCase { + return SendTransactionUseCase( + isDemoCardUseCase = isDemoCardUseCase, + cardSdkConfigRepository = cardSdkConfigRepository, + walletManagersFacade = walletManagersFacade, + ) + } + + @Provides + @Singleton + fun provideInitialToCurrencyResolver( + @SwapScope getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + swapTransactionRepository: SwapTransactionRepository, + ): InitialToCurrencyResolver { + return DefaultInitialToCurrencyResolver( + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + swapTransactionRepository = swapTransactionRepository, + ) + } } @Qualifier diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt index fbcbeb51b0..2c3a3e6af6 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/DataError.kt @@ -1,20 +1,36 @@ package com.tangem.feature.swap.domain.models +import java.math.BigDecimal + sealed class DataError { - object NoError : DataError() - data class UnknownError(val message: String) : DataError() - object InsufficientLiquidity : DataError() -} -fun mapErrors(error: String?): DataError { - return if (error == null) { - DataError.NoError - } else { - when (error) { - INSUFFICIENT_LIQUIDITY_ERROR -> DataError.InsufficientLiquidity - else -> DataError.UnknownError(error) - } + abstract val code: Int + + data class BadRequest(override val code: Int) : DataError() + + data class ExchangeProviderNotFoundError(override val code: Int) : DataError() + + data class ExchangeProviderNotActiveError(override val code: Int) : DataError() + + data class ExchangeProviderNotAvailableError(override val code: Int) : DataError() + + data class ExchangeNotPossibleError(override val code: Int) : DataError() + + data class ExchangeTooSmallAmountError(override val code: Int, val amount: SwapAmount) : DataError() + + data class ExchangeNotEnoughAllowanceError(override val code: Int, val currentAllowance: BigDecimal) : DataError() + + data class ExchangeNotEnoughBalanceError(override val code: Int) : DataError() + + data class ExchangeInvalidAddressError(override val code: Int) : DataError() + + data class ExchangeInvalidFromDecimalsError( + override val code: Int, + val receivedFromDecimals: Int, + val expressFromDecimals: Int, + ) : DataError() + + object UnknownError : DataError() { + override val code: Int = -1 } -} - -private const val INSUFFICIENT_LIQUIDITY_ERROR = "insufficient liquidity" \ No newline at end of file +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt index 317b0da94f..45a895181e 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/data/AggregatedSwapDataModel.kt @@ -11,5 +11,5 @@ import com.tangem.feature.swap.domain.models.DataError */ data class AggregatedSwapDataModel( val dataModel: T?, - val error: DataError = DataError.NoError, + val error: DataError = DataError.UnknownError, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt new file mode 100644 index 0000000000..41d7826e07 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeQuote.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.domain.models.domain + +data class ExchangeQuote( + val toAmount: String, + val allowanceContract: String?, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt new file mode 100644 index 0000000000..dc5c5738be --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt @@ -0,0 +1,20 @@ +package com.tangem.feature.swap.domain.models.domain + +data class ExchangeStatusModel( + val providerId: String, + val status: ExchangeStatus? = null, + val txId: String? = null, + val txUrl: String? = null, +) + +enum class ExchangeStatus { + New, + Waiting, + Confirming, + Verifying, + Exchanging, + Failed, + Sending, + Finished, + Refunded, +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt new file mode 100644 index 0000000000..89ab7c6754 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/ExpressTransactionModel.kt @@ -0,0 +1,29 @@ +package com.tangem.feature.swap.domain.models.domain + +import com.tangem.feature.swap.domain.models.SwapAmount + +sealed class ExpressTransactionModel { + + abstract val fromAmount: SwapAmount + abstract val toAmount: SwapAmount + abstract val txId: String + abstract val txTo: String + + data class DEX( + override val fromAmount: SwapAmount, + override val toAmount: SwapAmount, + override val txId: String, + override val txTo: String, + val txFrom: String, + val txData: String, + ) : ExpressTransactionModel() + + data class CEX( + override val fromAmount: SwapAmount, + override val toAmount: SwapAmount, + override val txId: String, + override val txTo: String, + val externalTxId: String, + val externalTxUrl: String, + ) : ExpressTransactionModel() +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt new file mode 100644 index 0000000000..b9ed4f4af3 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/LeastTokenInfo.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.domain.models.domain + +data class LeastTokenInfo( + val contractAddress: String, + val network: String, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt index 7c0f6812fb..bd1d290190 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt @@ -1,5 +1,6 @@ package com.tangem.feature.swap.domain.models.domain +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData import com.tangem.feature.swap.domain.models.ui.TxFee @@ -15,7 +16,8 @@ import com.tangem.feature.swap.domain.models.ui.TxFee data class PermissionOptions( val approveData: RequestApproveStateData, val forTokenContractAddress: String, - val fromToken: Currency, + val fromToken: CryptoCurrency, + val spenderAddress: String, val approveType: SwapApproveType, val txFee: TxFee, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt index 8b9f1c963b..53cfae4ded 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt @@ -1,5 +1,7 @@ package com.tangem.feature.swap.domain.models.domain +import com.tangem.feature.swap.domain.models.SwapAmount + /** * Prepared swap config state that contains flags to determine * @@ -7,8 +9,16 @@ package com.tangem.feature.swap.domain.models.domain * @property isBalanceEnough shows is balance of token enough * @property isFeeEnough shows is amount of main coin enough for fee */ +// todo Refactor this state data class PreparedSwapConfigState( val isAllowedToSpend: Boolean, val isBalanceEnough: Boolean, val isFeeEnough: Boolean, -) \ No newline at end of file + val includeFeeInAmount: IncludeFeeInAmount, +) + +sealed class IncludeFeeInAmount { + data class Included(val amountSubtractFee: SwapAmount) : IncludeFeeInAmount() + object Excluded : IncludeFeeInAmount() + object BalanceNotEnough : IncludeFeeInAmount() +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt index 5e5d782666..df74da7af3 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/QuoteModel.kt @@ -9,4 +9,5 @@ import com.tangem.feature.swap.domain.models.SwapAmount */ data class QuoteModel( val toTokenAmount: SwapAmount, + val allowanceContract: String?, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt new file mode 100644 index 0000000000..2db24c66eb --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedLastSwappedCryptoCurrency.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.domain.models.domain + +data class SavedLastSwappedCryptoCurrency( + val userWalletId: String, + val cryptoCurrencyId: String, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt new file mode 100644 index 0000000000..f2e6a4d139 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt @@ -0,0 +1,19 @@ +package com.tangem.feature.swap.domain.models.domain + +import java.math.BigDecimal + +data class SavedSwapTransactionListModel( + val userWalletId: String, + val fromCryptoCurrencyId: String, + val toCryptoCurrencyId: String, + val transactions: List, +) + +data class SavedSwapTransactionModel( + val txId: String, + val timestamp: Long, + val fromCryptoAmount: BigDecimal, + val toCryptoAmount: BigDecimal, + val provider: SwapProvider, + val status: ExchangeStatusModel? = null, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt index 7c15701329..8b2927968f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt @@ -2,13 +2,7 @@ package com.tangem.feature.swap.domain.models.domain import com.tangem.feature.swap.domain.models.SwapAmount -/** - * Swap transaction model - * - * @property toTokenAmount amount "target" token - * @property transaction info about transaction - */ data class SwapDataModel( val toTokenAmount: SwapAmount, - val transaction: TransactionModel, + val transaction: ExpressTransactionModel, ) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt new file mode 100644 index 0000000000..76fa9a8238 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -0,0 +1,50 @@ +package com.tangem.feature.swap.domain.models.domain + +import com.tangem.domain.tokens.model.CryptoCurrencyStatus + +/** + * Domain layer representation of SwapPair data network model. + * + * @property from Short information about the token we want to change + * @property to Short information about the token we want to exchange for + * @property providers Exchange providers + */ +data class SwapPairLeast( + val from: LeastTokenInfo, + val to: LeastTokenInfo, + val providers: List, +) + +data class CryptoCurrencySwapInfo( + val currencyStatus: CryptoCurrencyStatus, + val providers: List, +) + +/** + * Provider that could swap given cryptocurrencies + * + * @property providerId provider id + * @property rateTypes supported rate types + */ +data class SwapProvider( + val providerId: String, + val rateTypes: List = emptyList(), + val name: String, + val type: ExchangeProviderType, + val imageLarge: String, +) + +enum class ExchangeProviderType { + DEX, + CEX, +} + +/** + * Rate type. + * + * Current implementation contains only float type, fixed will be supported later. + */ +enum class RateType { + FLOAT, + FIXED, +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt index 5defcf6bf3..01914d161b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/AmountFormatter.kt @@ -25,7 +25,7 @@ class AmountFormatter { * @param currency * @return formatted [String] */ - fun formatBigDecimalAmountToUI(amount: BigDecimal, decimals: Int, currency: String): String { + fun formatBigDecimalAmountToUI(amount: BigDecimal, decimals: Int, currency: String? = null): String { return amount.toFormattedCurrencyString(decimals, currency) } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 28809bc066..d031891ba3 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -1,7 +1,9 @@ package com.tangem.feature.swap.domain.models.ui +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.DataError import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState import com.tangem.feature.swap.domain.models.domain.SwapDataModel import java.math.BigDecimal @@ -17,9 +19,11 @@ sealed interface SwapState { isAllowedToSpend = false, isBalanceEnough = false, isFeeEnough = false, + includeFeeInAmount = IncludeFeeInAmount.Excluded, ), val permissionState: PermissionDataState = PermissionDataState.Empty, - val swapDataModel: SwapStateData? = null, + val swapDataModel: SwapDataModel? = null, + val txFee: TxFeeState, val tangemFee: Double, ) : SwapState @@ -29,7 +33,10 @@ sealed interface SwapState { val zeroAmountEquivalent: String, ) : SwapState - data class SwapError(val error: DataError) : SwapState + data class SwapError( + val fromTokenInfo: TokenSwapInfo, + val error: DataError, + ) : SwapState } sealed class PermissionDataState { @@ -51,32 +58,42 @@ sealed class PermissionDataState { data class TokenSwapInfo( val tokenAmount: SwapAmount, - val coinId: String, - val tokenWalletBalance: String, - val tokenFiatBalance: String, + val amountFiat: BigDecimal, + val cryptoCurrencyStatus: CryptoCurrencyStatus, ) data class RequestApproveStateData( val fee: TxFeeState, val approveData: String, val fromTokenAmount: SwapAmount, + val spenderAddress: String, ) -data class SwapStateData( - val fee: TxFeeState, - val swapModel: SwapDataModel, -) +// data class SwapStateData( +// val fee: TxFeeState, +// val swapModel: SwapDataModel, +// ) -data class TxFeeState( - val normalFee: TxFee, - val priorityFee: TxFee, -) +sealed class TxFeeState { + data class MultipleFeeState( + val normalFee: TxFee, + val priorityFee: TxFee, + ) : TxFeeState() + + data class SingleFeeState( + val fee: TxFee, + ) : TxFeeState() + + object Empty : TxFeeState() +} data class TxFee( val feeValue: BigDecimal, val gasLimit: Int, val feeFiatFormatted: String, val feeCryptoFormatted: String, + val decimals: Int, + val cryptoSymbol: String, val feeType: FeeType, ) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt new file mode 100644 index 0000000000..1d29633fec --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -0,0 +1,21 @@ +package com.tangem.feature.swap.domain.models.ui + +import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo + +data class TokensDataStateExpress( + val fromGroup: CurrenciesGroup, + val toGroup: CurrenciesGroup, +) { + companion object { + val EMPTY = + TokensDataStateExpress( + fromGroup = CurrenciesGroup(emptyList(), emptyList()), + toGroup = CurrenciesGroup(emptyList(), emptyList()), + ) + } +} + +data class CurrenciesGroup( + val available: List, + val unavailable: List, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt index ca95f626e3..a593888610 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TxState.kt @@ -6,6 +6,8 @@ sealed class TxState { val fromAmount: String? = null, val toAmount: String? = null, val txAddress: String, + val txExternalUrl: String? = null, + val timestamp: Long, ) : TxState() object UserCancelled : TxState() diff --git a/features/swap/presentation/build.gradle.kts b/features/swap/presentation/build.gradle.kts index 1068c49d28..b2ea5cda48 100644 --- a/features/swap/presentation/build.gradle.kts +++ b/features/swap/presentation/build.gradle.kts @@ -12,13 +12,20 @@ dependencies { implementation(projects.core.analytics) implementation(projects.core.analytics.models) implementation(projects.core.featuretoggles) + implementation(projects.core.navigation) implementation(projects.core.utils) implementation(projects.core.ui) implementation(projects.common) /** Domain modules **/ + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) /** AndroidX */ implementation(deps.androidx.activity.compose) @@ -28,8 +35,10 @@ dependencies { implementation(deps.androidx.browser) /** Compose */ + implementation(deps.arrow.core) implementation(deps.compose.foundation) implementation(deps.compose.material) + implementation(deps.compose.material3) implementation(deps.compose.ui.tooling) implementation(deps.compose.coil) implementation(deps.compose.constraintLayout) @@ -37,6 +46,7 @@ dependencies { /** Api */ implementation(projects.features.swap.api) + implementation(projects.features.tokendetails.api) /** Domain */ implementation(projects.features.swap.domain) diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 768624019f..b128e88b19 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -1,6 +1,10 @@ package com.tangem.feature.swap.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.swap.domain.models.ui.FeeType + +private const val SWAP_CATEGORY = "Swap" sealed class SwapEvents( event: String, @@ -13,14 +17,17 @@ sealed class SwapEvents( ) object SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked") - object ChooseTokenScreenOpened : SwapEvents(event = "Choose Token Screen Opened") - data class SearchTokenClicked(val currencySymbol: String?) : SwapEvents( - event = "Searched Token Clicked", + data class ChooseTokenScreenOpened(val availableTokens: Boolean) : SwapEvents( + event = "Choose Token Screen Opened", + params = mapOf("Available tokens" to if (availableTokens) "Yes" else "No"), + ) + + data class ChooseTokenScreenResult(val tokenChosen: Boolean, val token: String? = null) : SwapEvents( + event = "Choose Token Screen Result", params = buildMap { - if (currencySymbol != null) { - put("Token", currencySymbol) - } + put("Token Chosen", if (tokenChosen) "Yes" else "No") + token?.let { put("Token", it) } }, ) @@ -37,8 +44,61 @@ sealed class SwapEvents( ) object ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel") - object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") - object SwapInProgressScreen : SwapEvents(event = "Swap in Progress Screen Opened") -} -private const val SWAP_CATEGORY = "Swap" \ No newline at end of file + object ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") + + data class SwapInProgressScreen( + val provider: SwapProvider, + val commission: FeeType, // Market / Fast + val sendToken: String, + val receiveToken: String, + ) : SwapEvents( + event = "Swap in Progress Screen Opened", + params = mapOf( + "Provider" to provider.name, + "Commission" to if (commission == FeeType.NORMAL) "Market" else "Fast", + "Send Token" to sendToken, + "Receive Token" to receiveToken, + ), + ) + + object ProviderClicked : SwapEvents("Provider Clicked") + + data class ProviderChosen(val provider: SwapProvider) : SwapEvents( + event = "Provider Chosen", + params = mapOf("Provider" to provider.name), + ) + + data class ButtonStatus(val token: String) : SwapEvents( + event = "Button - Status", + params = mapOf("Token" to token), + ) + + data class ButtonExplore(val token: String) : SwapEvents( + event = "Button - Explore", + params = mapOf("Token" to token), + ) + + object NoticeNoAvailableTokensToSwap : SwapEvents("Notice - No Available Tokens To Swap") + + data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents( + event = "Notice - Not Enough Fee", + params = mapOf( + "Token" to token, + "Blockchain" to blockchain, + ), + ) + + data class NoticeProviderError( + val token: String, + val provider: SwapProvider, + val errorCode: Int, + ) : SwapEvents( + event = "Notice - Express Error", + params = mapOf( + "Token" to token, + "Provider" to provider.name, + "Error code" to errorCode.toString(), + ), + ) +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt index cee76edbb1..6f42bbd1d2 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt @@ -1,43 +1,113 @@ package com.tangem.feature.swap.converters import com.tangem.common.Provider -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.NetworkInfo -import com.tangem.feature.swap.domain.models.ui.FoundTokensState -import com.tangem.feature.swap.domain.models.ui.TokenWithBalance -import com.tangem.feature.swap.models.Network +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.extensions.getTintForTokenIcon +import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo +import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenBalanceData -import com.tangem.feature.swap.models.TokenToSelect +import com.tangem.feature.swap.models.TokenToSelectState +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList class TokensDataConverter( private val onSearchEntered: (String) -> Unit, private val onTokenSelected: (String) -> Unit, private val isBalanceHiddenProvider: Provider, -) { + private val appCurrencyProvider: Provider, +) : Converter { - fun convertWithNetwork(value: FoundTokensState, network: NetworkInfo): SwapSelectTokenStateHolder { + override fun convert(value: CurrenciesGroup): SwapSelectTokenStateHolder { + val availableTitle = TokenToSelectState.Title(stringReference("My tokens")) // todo replace with resource + val unavailableTitle = TokenToSelectState.Title(stringReference("My tokens")) // todo replace with resource return SwapSelectTokenStateHolder( - addedTokens = value.tokensInWallet.map { tokenWithBalanceToTokenToSelect(it) }, - otherTokens = value.loadedTokens.map { tokenWithBalanceToTokenToSelect(it) }, + availableTokens = value.available.map { tokenWithBalanceToTokenToSelect(it, true) } + .toMutableList() + .apply { + if (this.isNotEmpty()) { + this.add(0, availableTitle) + } + } + .toImmutableList(), + unavailableTokens = value.unavailable.map { tokenWithBalanceToTokenToSelect(it, false) } + .toMutableList() + .apply { + if (this.isNotEmpty()) { + this.add(0, unavailableTitle) + } + } + .toImmutableList(), onSearchEntered = onSearchEntered, onTokenSelected = onTokenSelected, - network = Network(network.name, network.blockchainId), ) } - private fun tokenWithBalanceToTokenToSelect(tokenWithBalance: TokenWithBalance): TokenToSelect { - return TokenToSelect( - id = tokenWithBalance.token.id, - name = tokenWithBalance.token.name, - symbol = tokenWithBalance.token.symbol, - iconUrl = tokenWithBalance.token.logoUrl, - isNative = tokenWithBalance.token is Currency.NativeToken, + private fun tokenWithBalanceToTokenToSelect( + cryptoCurrencySwapInfo: CryptoCurrencySwapInfo, + isAvailable: Boolean, + ): TokenToSelectState { + val cryptoCurrencyStatus = cryptoCurrencySwapInfo.currencyStatus + return TokenToSelectState.TokenToSelect( + id = cryptoCurrencyStatus.currency.id.value, + name = cryptoCurrencyStatus.currency.name, + symbol = cryptoCurrencyStatus.currency.symbol, + available = isAvailable, + tokenIcon = convertIcon(cryptoCurrencyStatus.currency, isAvailable), addedTokenBalanceData = TokenBalanceData( - amount = tokenWithBalance.tokenBalanceData?.amount, - amountEquivalent = tokenWithBalance.tokenBalanceData?.amountEquivalent, + amount = formatCryptoAmount(cryptoCurrencyStatus), + amountEquivalent = formatFiatAmount(cryptoCurrencyStatus, appCurrencyProvider.invoke()), isBalanceHidden = isBalanceHiddenProvider.invoke(), ), ) } + + private fun convertIcon(currency: CryptoCurrency, isAvailable: Boolean): TokenIconState { + return when (currency) { + is CryptoCurrency.Coin -> { + TokenIconState.CoinIcon( + url = currency.iconUrl, + fallbackResId = currency.networkIconResId, + isGrayscale = !isAvailable, + showCustomBadge = currency.isCustom, + ) + } + is CryptoCurrency.Token -> { + val isGrayscale = currency.network.isTestnet + val background = currency.tryGetBackgroundForTokenIcon(isGrayscale) + val tint = getTintForTokenIcon(background) + TokenIconState.TokenIcon( + url = currency.iconUrl, + isGrayscale = !isAvailable, + showCustomBadge = currency.isCustom, + networkBadgeIconResId = currency.networkIconResId, + fallbackTint = tint, + fallbackBackground = background, + ) + } + } + } + + private fun formatCryptoAmount(cryptoCurrencyStatus: CryptoCurrencyStatus): String { + return BigDecimalFormatter.formatCryptoAmount( + cryptoCurrencyStatus.value.amount, + cryptoCurrencyStatus.currency.symbol, + cryptoCurrencyStatus.currency.decimals, + ) + } + + private fun formatFiatAmount(cryptoCurrencyStatus: CryptoCurrencyStatus, appCurrency: AppCurrency): String { + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = cryptoCurrencyStatus.value.fiatAmount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapPresentationModule.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapPresentationModule.kt new file mode 100644 index 0000000000..441abf4a2e --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/di/SwapPresentationModule.kt @@ -0,0 +1,34 @@ +package com.tangem.feature.swap.di + +import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.NetworksRepository +import com.tangem.domain.tokens.repository.QuotesRepository +import com.tangem.feature.swap.domain.* +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +class SwapPresentationModule { + + @ViewModelScoped + @Provides + fun providesGetCryptoCurrenciesUseCase( + currenciesRepository: CurrenciesRepository, + dispatcherProvider: CoroutineDispatcherProvider, + quotesRepository: QuotesRepository, + networksRepository: NetworksRepository, + ): GetCryptoCurrencyStatusSyncUseCase { + return GetCryptoCurrencyStatusSyncUseCase( + currenciesRepository = currenciesRepository, + quotesRepository = quotesRepository, + networksRepository = networksRepository, + dispatchers = dispatcherProvider, + ) + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index 2e48e108cc..ac6762d7b7 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -1,27 +1,29 @@ package com.tangem.feature.swap.models +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + data class SwapSelectTokenStateHolder( - val addedTokens: List, - val otherTokens: List, - val network: Network, + val availableTokens: ImmutableList, + val unavailableTokens: ImmutableList, val onSearchEntered: (String) -> Unit, val onTokenSelected: (String) -> Unit, ) -data class TokenToSelect( - val id: String, - val name: String, - val symbol: String, - val iconUrl: String, - val isNative: Boolean, - val available: Boolean = true, - val addedTokenBalanceData: TokenBalanceData? = null, -) +sealed class TokenToSelectState { -data class Network( - val name: String, - val blockchainId: String, -) + data class Title(val title: TextReference) : TokenToSelectState() + + data class TokenToSelect( + val id: String, + val name: String, + val symbol: String, + val tokenIcon: TokenIconState, + val available: Boolean = true, + val addedTokenBalanceData: TokenBalanceData? = null, + ) : TokenToSelectState() +} data class TokenBalanceData( val amount: String?, diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 1fc4583726..e30eff225c 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -1,24 +1,32 @@ package com.tangem.feature.swap.models +import androidx.annotation.DrawableRes import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.states.Item import com.tangem.core.ui.components.states.SelectableItemsState +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.ui.TxFee +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.models.states.ProviderState data class SwapStateHolder( - val sendCardData: SwapCardData, - val receiveCardData: SwapCardData, + val sendCardData: SwapCardState, + val receiveCardData: SwapCardState, val networkCurrency: String, - val networkId: String, val blockchainId: String, // not the same as networkId, its local id in app - val fee: FeeState = FeeState.Empty, val warnings: List = emptyList(), val alert: SwapWarning.GenericWarning? = null, val updateInProgress: Boolean = false, + val providerState: ProviderState, + val fee: FeeItemState = FeeItemState.Empty, val permissionState: SwapPermissionState = SwapPermissionState.Empty, + val successState: SwapSuccessStateHolder? = null, val selectTokenState: SwapSelectTokenStateHolder? = null, + val bottomSheetConfig: TangemBottomSheetConfig? = null, val swapButton: SwapButton, @@ -29,21 +37,32 @@ data class SwapStateHolder( val onSuccess: (() -> Unit)? = null, val onMaxAmountSelected: (() -> Unit)? = null, val onShowPermissionBottomSheet: () -> Unit = {}, - val onCancelPermissionBottomSheet: () -> Unit = {}, ) -data class SwapCardData( - val type: TransactionCardType, - val amountEquivalent: String?, - val coinId: String?, - val amountTextFieldValue: TextFieldValue?, - val tokenIconUrl: String, - val tokenCurrency: String, - val balance: String, - val isBalanceHidden: Boolean, - val isNotNativeToken: Boolean, - val canSelectAnotherToken: Boolean = false, -) +sealed class SwapCardState { + + data class SwapCardData( + @DrawableRes val networkIconRes: Int?, + val type: TransactionCardType, + val amountEquivalent: String?, + val token: CryptoCurrencyStatus?, + val coinId: String?, + val amountTextFieldValue: TextFieldValue?, + val tokenIconUrl: String?, + val tokenCurrency: String, + val balance: String, + val isBalanceHidden: Boolean, + val isNotNativeToken: Boolean, + val canSelectAnotherToken: Boolean = false, + ) : SwapCardState() + + data class Empty( + val type: TransactionCardType, + val amountEquivalent: String?, + val amountTextFieldValue: TextFieldValue?, + val canSelectAnotherToken: Boolean = false, + ) : SwapCardState() +} data class SwapButton( val enabled: Boolean, @@ -87,8 +106,9 @@ sealed interface TransactionCardType { } sealed interface SwapWarning { - data class PermissionNeeded(val tokenCurrency: String) : SwapWarning + data class PermissionNeeded(val notificationConfig: NotificationConfig) : SwapWarning object InsufficientFunds : SwapWarning + data class NoAvailableTokensToSwap(val notificationConfig: NotificationConfig) : SwapWarning data class GenericWarning( val message: String? = null, val type: GenericWarningType = GenericWarningType.OTHER, @@ -101,7 +121,10 @@ sealed interface SwapWarning { * * @property priceImpact in format = 10 (means 10%) */ - data class HighPriceImpact(val priceImpact: Int) : SwapWarning + data class HighPriceImpact(val priceImpact: Int, val notificationConfig: NotificationConfig) : SwapWarning + data class TooSmallAmountWarning(val notificationConfig: NotificationConfig) : SwapWarning + data class UnableToCoverFeeWarning(val notificationConfig: NotificationConfig) : SwapWarning + data class GeneralWarning(val notificationConfig: NotificationConfig) : SwapWarning } enum class GenericWarningType { diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt index 56dc3525a8..cbe749c6a8 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt @@ -1,7 +1,23 @@ package com.tangem.feature.swap.models +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.extensions.TextReference + data class SwapSuccessStateHolder( - val fromTokenAmount: String, - val toTokenAmount: String, - val onSecondaryButtonClick: () -> Unit, + val timestamp: Long, + val txUrl: String, + val fee: TextReference, + val rate: TextReference, + val showStatusButton: Boolean, + val providerName: TextReference, + val providerType: TextReference, + val providerIcon: String, + val fromTokenAmount: TextReference, + val toTokenAmount: TextReference, + val fromTokenFiatAmount: TextReference, + val toTokenFiatAmount: TextReference, + val fromTokenIconState: TokenIconState?, + val toTokenIconState: TokenIconState?, + val onExploreButtonClick: () -> Unit, + val onStatusButtonClick: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt index d250e06df6..48a3d55f55 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -1,6 +1,5 @@ package com.tangem.feature.swap.models -import com.tangem.core.ui.components.states.Item import com.tangem.feature.swap.domain.models.ui.TxFee data class UiActions( @@ -14,7 +13,12 @@ data class UiActions( val onBackClicked: () -> Unit, val onMaxAmountSelected: () -> Unit, val openPermissionBottomSheet: () -> Unit, - val hidePermissionBottomSheet: () -> Unit, val onChangeApproveType: (ApproveType) -> Unit, - val onSelectItemFee: (Item) -> Unit, + // region new actions + val onRetryClick: () -> Unit, + val onClickFee: () -> Unit, + val onSelectFeeType: (TxFee) -> Unit, + val onProviderClick: (String) -> Unit, + val onProviderSelect: (String) -> Unit, + val onBuyClick: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt new file mode 100644 index 0000000000..fddf5f557e --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt @@ -0,0 +1,11 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.feature.swap.domain.models.ui.FeeType +import kotlinx.collections.immutable.ImmutableList + +data class ChooseFeeBottomSheetConfig( + val selectedFee: FeeType, + val onSelectFeeType: (FeeType) -> Unit, + val feeItems: ImmutableList, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseProviderBottomSheetConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseProviderBottomSheetConfig.kt new file mode 100644 index 0000000000..68c761cd14 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ChooseProviderBottomSheetConfig.kt @@ -0,0 +1,9 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import kotlinx.collections.immutable.ImmutableList + +data class ChooseProviderBottomSheetConfig( + val selectedProviderId: String, + val providers: ImmutableList, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt new file mode 100644 index 0000000000..e01a8b8058 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt @@ -0,0 +1,19 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.feature.swap.domain.models.ui.FeeType + +sealed class FeeItemState { + + data class Content( + val feeType: FeeType, + val title: TextReference, + val amountCrypto: String, + val symbolCrypto: String, + val amountFiatFormatted: String, + val isClickable: Boolean, + val onClick: () -> Unit, + ) : FeeItemState() + + object Empty : FeeItemState() +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/GivePermissionBottomSheetConfig.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/GivePermissionBottomSheetConfig.kt new file mode 100644 index 0000000000..9a8bd7a519 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/GivePermissionBottomSheetConfig.kt @@ -0,0 +1,9 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.feature.swap.models.SwapPermissionState + +data class GivePermissionBottomSheetConfig( + val data: SwapPermissionState.ReadyForRequest, + val onCancel: () -> Unit, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt new file mode 100644 index 0000000000..84254a5716 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/models/states/ProviderState.kt @@ -0,0 +1,67 @@ +package com.tangem.feature.swap.models.states + +import com.tangem.core.ui.extensions.TextReference + +sealed class ProviderState { + + abstract val onProviderClick: ((String) -> Unit)? + abstract val id: String + + data class Empty( + override val id: String = "", + override val onProviderClick: ((String) -> Unit)? = null, + ) : ProviderState() + + data class Loading( + override val id: String = "", + override val onProviderClick: ((String) -> Unit)? = null, + ) : ProviderState() + + data class Content( + override val id: String, + val name: String, + val type: String, + val iconUrl: String, + val subtitle: TextReference, + val selectionType: SelectionType, + val additionalBadge: AdditionalBadge, + val percentLowerThenBest: Float = 0f, + override val onProviderClick: (String) -> Unit, + ) : ProviderState() + + data class Unavailable( + override val id: String, + val name: String, + val type: String, + val iconUrl: String, + val alertText: TextReference, + val selectionType: SelectionType, + override val onProviderClick: ((String) -> Unit)? = null, + ) : ProviderState() + + sealed class AdditionalBadge { + object BestTrade : AdditionalBadge() + object Empty : AdditionalBadge() + object PermissionRequired : AdditionalBadge() + } + + enum class SelectionType { + NONE, CLICK, SELECT + } +} + +object ProviderPercentDiffComparator : Comparator { + override fun compare(o1: ProviderState, o2: ProviderState): Int { + if (o1 is ProviderState.Content && o2 !is ProviderState.Content) { + return 1 + } + if (o1 !is ProviderState.Content && o2 is ProviderState.Content) { + return -1 + } + return if (o1 is ProviderState.Content && o2 is ProviderState.Content) { + o1.percentLowerThenBest.compareTo(o2.percentLowerThenBest) + } else { + 0 + } + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt index 8562c1e5db..3b16f0df21 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/presentation/SwapFragment.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.Crossfade import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.fragment.app.viewModels +import com.tangem.core.navigation.ReduxNavController import com.tangem.core.ui.components.SystemBarsEffect import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeFragment @@ -26,6 +27,9 @@ class SwapFragment : ComposeFragment() { @Inject override lateinit var appThemeModeHolder: AppThemeModeHolder + @Inject + lateinit var reduxNavController: ReduxNavController + private val viewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -35,6 +39,7 @@ class SwapFragment : ComposeFragment() { SwapRouter( fragmentManager = WeakReference(parentFragmentManager), customTabsManager = CustomTabsManager(WeakReference(context)), + reduxNavController = reduxNavController, ), ) } @@ -82,7 +87,5 @@ class SwapFragment : ComposeFragment() { companion object { const val CURRENCY_BUNDLE_KEY = "swap_currency" - const val DERIVATION_PATH = "DERIVATION_STYLE" - const val NETWORK = "NETWORK" } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt index 62ce0901c5..081730868d 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt @@ -3,12 +3,20 @@ package com.tangem.feature.swap.router import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager +import com.tangem.core.navigation.AppScreen +import com.tangem.core.navigation.NavigationAction +import com.tangem.core.navigation.ReduxNavController +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.features.tokendetails.navigation.TokenDetailsRouter import java.lang.ref.WeakReference internal class SwapRouter( private val fragmentManager: WeakReference, private val customTabsManager: CustomTabsManager, + private val reduxNavController: ReduxNavController, ) { var currentScreen by mutableStateOf(SwapNavScreen.Main) @@ -29,6 +37,18 @@ internal class SwapRouter( fun openUrl(url: String) { customTabsManager.openUrl(url) } + + fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) { + reduxNavController.navigate( + action = NavigationAction.NavigateTo( + screen = AppScreen.WalletDetails, + bundle = bundleOf( + TokenDetailsRouter.USER_WALLET_ID_KEY to userWalletId.stringValue, + TokenDetailsRouter.CRYPTO_CURRENCY_KEY to currency, + ), + ), + ) + } } enum class SwapNavScreen { diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt new file mode 100644 index 0000000000..d0f47a62a2 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt @@ -0,0 +1,148 @@ +package com.tangem.feature.swap.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.SpacerH24 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.rows.SelectorRowItem +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.presentation.R +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun ChooseFeeBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet(config) { content: ChooseFeeBottomSheetConfig -> + ChooseFeeBottomSheetContent(content = content) + } +} + +@Composable +private fun ChooseFeeBottomSheetContent(content: ChooseFeeBottomSheetConfig) { + Column( + modifier = Modifier.background(TangemTheme.colors.background.primary), + ) { + Text( + text = stringResource(R.string.common_fee_selector_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing10) + .align(Alignment.CenterHorizontally), + ) + Column( + modifier = Modifier + .padding(TangemTheme.dimens.spacing16) + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ), + ) { + FeeItemsBlock(content) + } + Text( + text = stringResource(R.string.common_fee_selector_footer), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier + .padding( + vertical = TangemTheme.dimens.spacing8, + horizontal = TangemTheme.dimens.spacing16, + ) + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Start, + ) + } +} + +@Composable +private fun FeeItemsBlock(content: ChooseFeeBottomSheetConfig) { + content.feeItems.forEach { feeItem -> + val isSelected = feeItem.feeType == content.selectedFee + val preEllipsizeText = feeItem.amountCrypto + val postEllipsizeText = " ${feeItem.symbolCrypto} (${feeItem.amountFiatFormatted})" + when (feeItem.feeType) { + FeeType.NORMAL -> { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_market, + iconRes = R.drawable.ic_bird_24, + preEllipsize = TextReference.Str(preEllipsizeText), + postEllipsize = TextReference.Str(postEllipsizeText), + isSelected = isSelected, + onSelect = { content.onSelectFeeType(feeItem.feeType) }, + ) + } + FeeType.PRIORITY -> { + SelectorRowItem( + titleRes = R.string.common_fee_selector_option_fast, + iconRes = R.drawable.ic_hare_24, + preEllipsize = TextReference.Str(preEllipsizeText), + postEllipsize = TextReference.Str(postEllipsizeText), + isSelected = isSelected, + onSelect = { content.onSelectFeeType(feeItem.feeType) }, + ) + } + } + } +} + +@Preview +@Composable +private fun ChooseFeeBottomSheetContent_Preview() { + val feeItems = listOf( + FeeItemState.Content( + feeType = FeeType.NORMAL, + title = stringReference("Fee"), + amountCrypto = "1000", + symbolCrypto = "MATIC", + amountFiatFormatted = "(10$)", + isClickable = false, + onClick = {}, + ), + FeeItemState.Content( + feeType = FeeType.PRIORITY, + title = stringReference("Fee"), + amountCrypto = "2000", + symbolCrypto = "MATIC", + amountFiatFormatted = "(10$)", + isClickable = false, + onClick = {}, + ), + ).toImmutableList() + Column { + TangemTheme(isDark = true) { + ChooseFeeBottomSheetContent( + ChooseFeeBottomSheetConfig( + selectedFee = FeeType.NORMAL, + onSelectFeeType = {}, + feeItems = feeItems, + ), + ) + } + + SpacerH24() + + TangemTheme(isDark = false) { + ChooseFeeBottomSheetContent( + ChooseFeeBottomSheetConfig( + selectedFee = FeeType.NORMAL, + onSelectFeeType = {}, + feeItems = feeItems, + ), + ) + } + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt new file mode 100644 index 0000000000..2dd33c473f --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ChooseProviderBottomSheet.kt @@ -0,0 +1,113 @@ +package com.tangem.feature.swap.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.material.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.stringResource +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.TangemBottomSheetConfig +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig +import com.tangem.feature.swap.models.states.ProviderState +import com.tangem.feature.swap.presentation.R +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + contentColor = TangemTheme.colors.background.tertiary, + ) { content: ChooseProviderBottomSheetConfig -> + ChooseProviderBottomSheetContent(content = content) + } +} + +@Composable +private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetConfig) { + Column { + Text( + text = stringResource(R.string.express_choose_providers_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing10) + .align(Alignment.CenterHorizontally), + ) + Text( + text = stringResource(R.string.express_choose_providers_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing10) + .padding(horizontal = TangemTheme.dimens.spacing56) + .align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center, + ) + Column( + modifier = Modifier + .padding(TangemTheme.dimens.spacing16) + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium), + ) { + content.providers.forEach { provider -> + val isSelected = provider.id == content.selectedProviderId + ProviderItem( + state = provider, + isSelected = isSelected, + modifier = Modifier + .clickable( + enabled = provider.onProviderClick != null, + onClick = { provider.onProviderClick?.invoke(provider.id) }, + ) + .padding(TangemTheme.dimens.spacing12), + ) + } + } + } +} + +@Preview +@Composable +private fun ChooseProviderBottomSheet_Preview() { + val providers = listOf( + ProviderState.Content( + id = "1", + name = "1inch", + type = "DEX", + iconUrl = "", + subtitle = stringReference("1 000 000"), + additionalBadge = ProviderState.AdditionalBadge.BestTrade, + percentLowerThenBest = -1.0f, + selectionType = ProviderState.SelectionType.SELECT, + onProviderClick = {}, + ), + ProviderState.Unavailable( + id = "2", + name = "1inch", + type = "DEX", + iconUrl = "", + selectionType = ProviderState.SelectionType.SELECT, + alertText = stringReference("Unavailable"), + ), + ) + TangemTheme(isDark = false) { + ChooseProviderBottomSheetContent( + ChooseProviderBottomSheetConfig( + selectedProviderId = "1", + providers = providers.toImmutableList(), + ), + ) + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt new file mode 100644 index 0000000000..f379ae3511 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt @@ -0,0 +1,76 @@ +package com.tangem.feature.swap.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.SpacerH24 +import com.tangem.core.ui.components.rows.SimpleActionRow +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.ui.FeeType +import com.tangem.feature.swap.models.states.FeeItemState + +@Composable +fun FeeItemBlock(state: FeeItemState) { + if (state is FeeItemState.Content) { + FeeItem(state = state) + } +} + +@Composable +fun FeeItem(state: FeeItemState.Content) { + Box( + modifier = Modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .clickable( + onClick = state.onClick, + ) + .fillMaxWidth() + .defaultMinSize(minHeight = TangemTheme.dimens.size68), + ) { + val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})" + SimpleActionRow( + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + ), + title = state.title.resolveReference(), + description = description, + isClickable = state.isClickable, + ) + } +} + +@Preview +@Composable +private fun FeeItemPreview() { + val state = FeeItemState.Content( + feeType = FeeType.NORMAL, + title = stringReference("Fee"), + amountCrypto = "1000", + symbolCrypto = "MATIC", + amountFiatFormatted = "(1000$)", + isClickable = false, + onClick = {}, + ) + Column { + TangemTheme(isDark = false) { + FeeItem(state = state) + } + + SpacerH24() + + TangemTheme(isDark = true) { + FeeItem(state = state) + } + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt new file mode 100644 index 0000000000..e5fe4ef2c9 --- /dev/null +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/ProviderItem.kt @@ -0,0 +1,452 @@ +package com.tangem.feature.swap.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +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 coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.models.states.ProviderState + +/** + * UI Item for swap provider wrapped in a [BaseContainer] with rounded corners + * + * https://www.figma.com/file/Vs6SkVsFnUPsSCNwlnVf5U/Android-%E2%80%93-UI?type=design&node-id=7856-41909&mode=design&t=vo7dyElitnzSPSW3-4 + */ + +private const val GRAY_SCALE_SATURATION = 0f +private const val GRAY_SCALE_ALPHA = 0.4f +private val GrayscaleColorFilter: ColorFilter + get() = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(GRAY_SCALE_SATURATION) }) + +@Composable +fun ProviderItemBlock(state: ProviderState, modifier: Modifier = Modifier) { + if (state !is ProviderState.Empty) { + BaseContainer(modifier = modifier) { + ProviderItem( + state = state, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + } +} + +@Composable +fun ProviderItem(state: ProviderState, modifier: Modifier = Modifier, isSelected: Boolean = false) { + when (state) { + is ProviderState.Content -> { + ProviderContentState( + state = state, + modifier = modifier, + isSelected = isSelected, + ) + } + is ProviderState.Loading -> { + ProviderLoadingState( + modifier = modifier, + ) + } + is ProviderState.Unavailable -> { + ProviderUnavailableState( + state = state, + modifier = modifier, + isSelected = isSelected, + ) + } + is ProviderState.Empty -> { + // do nothing + } + } +} + +@Suppress("LongMethod") +@Composable +private fun ProviderContentState( + state: ProviderState.Content, + modifier: Modifier = Modifier, + isSelected: Boolean = false, +) { + Box(modifier = modifier.fillMaxWidth()) { + Row { + SubcomposeAsyncImage( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .size(size = TangemTheme.dimens.size40) + .clip(TangemTheme.shapes.roundedCorners8), + model = ImageRequest.Builder(context = LocalContext.current) + .data(state.iconUrl) + .crossfade(enable = true) + .allowHardware(false) + .build(), + loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) }, + error = { + ErrorProviderIcon( + Modifier.size( + size = TangemTheme.dimens.size40, + ), + ) + }, + contentDescription = null, + ) + + Column( + modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), + ) { + Row { + AnimatedContent(targetState = state.name, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.primary1, + ) + } + AnimatedContent(targetState = state.type, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + } + when (state.additionalBadge) { + ProviderState.AdditionalBadge.BestTrade -> + BestTradeItem(Modifier.padding(start = TangemTheme.dimens.spacing4)) + ProviderState.AdditionalBadge.PermissionRequired -> + PermissionBadgeItem(Modifier.padding(start = TangemTheme.dimens.spacing4)) + ProviderState.AdditionalBadge.Empty -> { + // no-op + } + } + } + Row( + modifier = Modifier.padding( + top = TangemTheme.dimens.spacing8, + end = TangemTheme.dimens.spacing56, + ), + ) { + AnimatedContent(targetState = state.subtitle, label = "") { + Text( + text = it.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + if (state.percentLowerThenBest > 0f) { + AnimatedContent(targetState = state.percentLowerThenBest, label = "") { + Text( + text = "-$it%", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.warning, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + } + } + } + } + + ProviderChevron(selectionType = state.selectionType, isSelected = isSelected) + } +} + +@Composable +private fun ProviderUnavailableState( + state: ProviderState.Unavailable, + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxWidth()) { + Row { + val (alpha, colorFilter) = GRAY_SCALE_ALPHA to GrayscaleColorFilter + SubcomposeAsyncImage( + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .size(size = TangemTheme.dimens.size40) + .clip(TangemTheme.shapes.roundedCorners8), + model = ImageRequest.Builder(context = LocalContext.current) + .data(state.iconUrl) + .crossfade(enable = true) + .allowHardware(false) + .build(), + loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) }, + error = { + ErrorProviderIcon( + Modifier.size( + size = TangemTheme.dimens.size40, + ), + ) + }, + alpha = alpha, + colorFilter = colorFilter, + contentDescription = null, + ) + + Column( + modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), + ) { + Row { + AnimatedContent(targetState = state.name, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + AnimatedContent(targetState = state.type, label = "") { + Text( + text = it, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + } + } + AnimatedContent(targetState = state.alertText, label = "") { + Text( + text = it.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + ) + } + } + } + + ProviderChevron(selectionType = state.selectionType, isSelected = isSelected) + } +} + +@Composable +private fun ProviderLoadingState(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxWidth()) { + Column { + Text( + text = "Provider", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing12), + ) + + Row( + modifier = Modifier.padding( + top = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing12, + ), + ) { + CircularProgressIndicator( + modifier = Modifier.size(TangemTheme.dimens.size16), + color = TangemTheme.colors.icon.informative, + ) + Text( + text = "Fetching best rates ...", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + } + } + + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + tint = TangemTheme.colors.icon.informative, + ) + } +} + +@Composable +private fun BoxScope.ProviderChevron(selectionType: ProviderState.SelectionType, isSelected: Boolean) { + when (selectionType) { + ProviderState.SelectionType.NONE -> { + /* no-op */ + } + ProviderState.SelectionType.CLICK -> { + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + tint = TangemTheme.colors.icon.informative, + ) + } + ProviderState.SelectionType.SELECT -> { + if (isSelected) { + Icon( + painter = painterResource(id = R.drawable.ic_check_24), + contentDescription = null, + modifier = Modifier + .align(alignment = Alignment.CenterEnd) + .padding(end = TangemTheme.dimens.spacing12), + tint = TangemTheme.colors.icon.accent, + ) + } + } + } +} + +@Composable +private fun BaseContainer(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { + Box( + modifier = modifier + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .clip(shape = TangemTheme.shapes.roundedCornersXMedium) + .fillMaxWidth() + .defaultMinSize(minHeight = TangemTheme.dimens.size68), + ) { + content() + } +} + +@Composable +private fun ErrorProviderIcon(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background( + color = TangemTheme.colors.background.secondary, + shape = TangemTheme.shapes.roundedCorners8, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.matchParentSize(), + painter = painterResource(id = R.drawable.ic_custom_token_44), + contentDescription = null, + ) + } +} + +@Composable +private fun BestTradeItem(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = TangemTheme.shapes.roundedCornersLarge, + ), + ) { + Text( + text = "Best rate", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.icon.accent, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6), + ) + } +} + +@Composable +private fun PermissionBadgeItem(modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors.background.secondary, + shape = TangemTheme.shapes.roundedCornersLarge, + ), + ) { + Text( + text = "Permission required", + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6), + ) + } +} + +@Preview +@Composable +private fun ProviderItem_Loading_Preview() { + Column { + TangemTheme(isDark = false) { + ProviderItemBlock(state = ProviderState.Loading()) + } + + SpacerH24() + + TangemTheme(isDark = true) { + ProviderItemBlock(state = ProviderState.Loading()) + } + } +} + +@Preview +@Composable +private fun ProviderItem_Content_Preview() { + val state = ProviderState.Content( + id = "1", + name = "1inch", + type = "DEX", + iconUrl = "", + subtitle = stringReference("1 000 000"), + additionalBadge = ProviderState.AdditionalBadge.PermissionRequired, + percentLowerThenBest = -1.0f, + selectionType = ProviderState.SelectionType.SELECT, + onProviderClick = {}, + ) + Column { + TangemTheme(isDark = false) { + ProviderItemBlock(state = state) + } + + SpacerH24() + + TangemTheme(isDark = true) { + ProviderItemBlock(state = state) + } + } +} + +@Preview +@Composable +private fun ProviderItem_Unavailable_Preview() { + val state = ProviderState.Unavailable( + id = "1", + name = "1inch", + type = "DEX", + iconUrl = "", + selectionType = ProviderState.SelectionType.SELECT, + alertText = stringReference("Unavailable"), + ) + Column { + TangemTheme(isDark = false) { + ProviderItemBlock(state = state) + } + + SpacerH24() + + TangemTheme(isDark = true) { + ProviderItemBlock(state = state) + } + + SpacerH24() + + TangemTheme(isDark = true) { + ProviderItem(state = state, isSelected = true) + } + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 75b0e2b05f..74a81982cb 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -3,62 +3,81 @@ package com.tangem.feature.swap.ui import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.Provider -import com.tangem.core.ui.components.states.Item -import com.tangem.core.ui.components.states.SelectableItemsState -import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.swap.converters.TokensDataConverter import com.tangem.feature.swap.domain.models.DataError -import com.tangem.feature.swap.domain.models.domain.Currency -import com.tangem.feature.swap.domain.models.domain.NetworkInfo -import com.tangem.feature.swap.domain.models.domain.isNonNative +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.formatToUIRepresentation import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.* import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.viewmodels.SwapProcessDataState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.min /** * State builder creates a specific states for SwapScreen */ -@Suppress("LargeClass") -internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: Provider) { +@Suppress("LargeClass", "TooManyFunctions") +internal class StateBuilder( + private val actions: UiActions, + private val isBalanceHiddenProvider: Provider, + private val appCurrencyProvider: Provider, +) { + + private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) private val tokensDataConverter = TokensDataConverter( onSearchEntered = actions.onSearchEntered, onTokenSelected = actions.onTokenSelected, isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, ) - fun createInitialLoadingState(initialCurrency: Currency, networkInfo: NetworkInfo): SwapStateHolder { + fun createInitialLoadingState(initialCurrency: CryptoCurrency, networkInfo: NetworkInfo): SwapStateHolder { return SwapStateHolder( - networkId = initialCurrency.networkId, blockchainId = networkInfo.blockchainId, - sendCardData = SwapCardData( + sendCardData = SwapCardState.SwapCardData( type = TransactionCardType.SendCard(actions.onAmountChanged, actions.onAmountSelected), amountEquivalent = null, amountTextFieldValue = null, - tokenIconUrl = initialCurrency.logoUrl, + token = null, + tokenIconUrl = initialCurrency.iconUrl, tokenCurrency = initialCurrency.symbol, - coinId = initialCurrency.id, + coinId = initialCurrency.network.backendId, canSelectAnotherToken = false, - isNotNativeToken = initialCurrency.isNonNative(), + isNotNativeToken = initialCurrency is CryptoCurrency.Token, balance = "", + networkIconRes = getActiveIconRes(initialCurrency.network.id.value), isBalanceHidden = true, ), - receiveCardData = SwapCardData( + receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountEquivalent = null, tokenIconUrl = "", tokenCurrency = "", + token = null, amountTextFieldValue = null, canSelectAnotherToken = false, balance = "", isNotNativeToken = false, + networkIconRes = null, coinId = null, isBalanceHidden = true, ), - fee = FeeState.Loading, + fee = FeeItemState.Empty, networkCurrency = networkInfo.blockchainCurrency, swapButton = SwapButton(enabled = false, loading = true, onClick = {}), onRefresh = {}, @@ -67,45 +86,105 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: onMaxAmountSelected = actions.onMaxAmountSelected, updateInProgress = true, onShowPermissionBottomSheet = actions.openPermissionBottomSheet, - onCancelPermissionBottomSheet = actions.hidePermissionBottomSheet, + providerState = ProviderState.Empty(), + ) + } + + fun createNoAvailableTokensToSwapState( + uiStateHolder: SwapStateHolder, + fromToken: CryptoCurrencyStatus, + ): SwapStateHolder { + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + return uiStateHolder.copy( + sendCardData = SwapCardState.SwapCardData( + type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), + amountTextFieldValue = TextFieldValue( + text = "0", + ), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + token = fromToken, + tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, + coinId = uiStateHolder.sendCardData.coinId, + isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, + tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, + canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, + balance = fromToken.getFormattedAmount(), + networkIconRes = getActiveIconRes(fromToken.currency.network.id.value), + isBalanceHidden = isBalanceHiddenProvider(), + ), + receiveCardData = SwapCardState.Empty( + type = TransactionCardType.ReceiveCard(), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + amountTextFieldValue = TextFieldValue( + text = "0", + ), + canSelectAnotherToken = true, + ), + warnings = listOf( + SwapWarning.NoAvailableTokensToSwap( + notificationConfig = NotificationConfig( + title = resourceReference(R.string.warning_express_no_exchangeable_coins_title), + subtitle = resourceReference( + id = R.string.warning_express_no_exchangeable_coins_description, + formatArgs = wrappedList(fromToken.currency.name), + ), + iconResId = R.drawable.img_attention_20, + ), + ), + ), + fee = FeeItemState.Empty, + swapButton = SwapButton( + enabled = false, + loading = false, + onClick = { }, + ), + updateInProgress = false, ) } fun createQuotesLoadingState( uiStateHolder: SwapStateHolder, - fromToken: Currency, - toToken: Currency, + fromToken: CryptoCurrency, + toToken: CryptoCurrency, mainTokenId: String, ): SwapStateHolder { - val canSelectSendToken = mainTokenId != fromToken.id - val canSelectReceiveToken = mainTokenId != toToken.id + val canSelectSendToken = mainTokenId != fromToken.id.value + val canSelectReceiveToken = mainTokenId != toToken.id.value + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardData( + sendCardData = SwapCardState.SwapCardData( type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = null, - tokenIconUrl = fromToken.logoUrl, + token = uiStateHolder.sendCardData.token, + tokenIconUrl = fromToken.iconUrl, tokenCurrency = fromToken.symbol, - coinId = fromToken.id, - isNotNativeToken = fromToken.isNonNative(), + coinId = fromToken.network.backendId, + isNotNativeToken = fromToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectSendToken, balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "", + networkIconRes = getActiveIconRes(fromToken.network.id.value), isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardData( + receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountTextFieldValue = null, amountEquivalent = null, - tokenIconUrl = toToken.logoUrl, + token = uiStateHolder.receiveCardData.token, + tokenIconUrl = toToken.iconUrl, tokenCurrency = toToken.symbol, - coinId = toToken.id, - isNotNativeToken = toToken.isNonNative(), + coinId = toToken.network.backendId, + isNotNativeToken = toToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectReceiveToken, balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "", + networkIconRes = getActiveIconRes(toToken.network.id.value), isBalanceHidden = isBalanceHiddenProvider(), ), - fee = FeeState.Loading, + warnings = emptyList(), + fee = FeeItemState.Empty, swapButton = SwapButton(enabled = false, loading = true, onClick = {}), + providerState = ProviderState.Loading(), permissionState = uiStateHolder.permissionState, updateInProgress = true, ) @@ -117,53 +196,51 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: * @param uiStateHolder whole screen state * @param quoteModel data model * @param fromToken token data to swap - * @param onFeeSetup callback for reset fee after auto update * @return updated whole screen state */ + @Suppress("LongMethod", "LongParameterList") fun createQuotesLoadedState( uiStateHolder: SwapStateHolder, quoteModel: SwapState.QuotesLoadedState, - fromToken: Currency, - onFeeSetup: (TxFee) -> Unit, + fromToken: CryptoCurrency, + swapProvider: SwapProvider, + bestRatedProviderId: String, + isManyProviders: Boolean, + selectedFeeType: FeeType, ): SwapStateHolder { - val warnings = mutableListOf() - if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && - quoteModel.preparedSwapConfigState.isFeeEnough && - quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest - ) { - warnings.add(SwapWarning.PermissionNeeded(fromToken.symbol)) - } - if (!quoteModel.preparedSwapConfigState.isBalanceEnough) { - warnings.add(SwapWarning.InsufficientFunds) - } - - if (quoteModel.priceImpact > PRICE_IMPACT_THRESHOLD) { - warnings.add(SwapWarning.HighPriceImpact((quoteModel.priceImpact * HUNDRED_PERCENTS).toInt())) - } - val feeState = createFeeState(quoteModel, uiStateHolder, onFeeSetup) + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + val warnings = getWarningsForSuccessState(quoteModel, fromToken) + val feeState = createFeeState(quoteModel.txFee, selectedFeeType) + val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus + val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus return uiStateHolder.copy( - sendCardData = SwapCardData( + sendCardData = SwapCardState.SwapCardData( type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, - amountEquivalent = quoteModel.fromTokenInfo.tokenFiatBalance, + amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat), + token = fromCurrencyStatus, tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = quoteModel.fromTokenInfo.coinId, + coinId = fromCurrencyStatus.currency.network.backendId, isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - balance = quoteModel.fromTokenInfo.tokenWalletBalance, + networkIconRes = uiStateHolder.sendCardData.networkIconRes, + balance = fromCurrencyStatus.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardData( + receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountTextFieldValue = TextFieldValue(quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation()), - amountEquivalent = quoteModel.toTokenInfo.tokenFiatBalance, + amountEquivalent = getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat), + token = toCurrencyStatus, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = quoteModel.toTokenInfo.coinId, + coinId = toCurrencyStatus.currency.network.backendId, isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - balance = quoteModel.toTokenInfo.tokenWalletBalance, + networkIconRes = uiStateHolder.receiveCardData.networkIconRes, + balance = toCurrencyStatus.getFormattedAmount(), isBalanceHidden = isBalanceHiddenProvider(), ), networkCurrency = quoteModel.networkCurrency, @@ -171,59 +248,253 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: permissionState = convertPermissionState( lastPermissionState = uiStateHolder.permissionState, permissionDataState = quoteModel.permissionState, - feeState = feeState, onGivePermissionClick = actions.onGivePermissionClick, onChangeApproveType = actions.onChangeApproveType, ), fee = feeState, swapButton = SwapButton( - enabled = quoteModel.preparedSwapConfigState.isAllowedToSpend && - quoteModel.preparedSwapConfigState.isBalanceEnough && - quoteModel.preparedSwapConfigState.isFeeEnough, + enabled = getSwapButtonEnabled(quoteModel.preparedSwapConfigState), loading = false, onClick = actions.onSwapClick, ), updateInProgress = false, + providerState = swapProvider.convertToContentClickableProviderState( + isBestRate = bestRatedProviderId == swapProvider.providerId, + fromTokenInfo = quoteModel.fromTokenInfo, + toTokenInfo = quoteModel.toTokenInfo, + isNeedBadge = isManyProviders, + selectionType = ProviderState.SelectionType.CLICK, + onProviderClick = actions.onProviderClick, + ), ) } + private fun getWarningsForSuccessState( + quoteModel: SwapState.QuotesLoadedState, + fromToken: CryptoCurrency, + ): List { + val warnings = mutableListOf() + if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && + quoteModel.preparedSwapConfigState.isFeeEnough && + quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest + ) { + warnings.add( + SwapWarning.PermissionNeeded( + createPermissionNotificationConfig(fromToken.symbol), + ), + ) + } + when (quoteModel.preparedSwapConfigState.includeFeeInAmount) { + is IncludeFeeInAmount.Included -> + warnings.add( + SwapWarning.GeneralWarning( + createNetworkFeeCoverageNotificationConfig(), + ), + ) + else -> Unit + } + if (!quoteModel.preparedSwapConfigState.isFeeEnough && + quoteModel.preparedSwapConfigState.isBalanceEnough + ) { + warnings.add( + SwapWarning.UnableToCoverFeeWarning( + createUnableToCoverFeeNotificationConfig( + fromToken = fromToken, + onBuyClick = actions.onBuyClick, + ), + ), + ) + } + // check isBalanceEnough, but for dex includeFeeInAmount always Excluded + if (!quoteModel.preparedSwapConfigState.isBalanceEnough && + quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included + ) { + warnings.add(SwapWarning.InsufficientFunds) + } + + if (quoteModel.priceImpact > PRICE_IMPACT_THRESHOLD) { + warnings.add( + SwapWarning.HighPriceImpact( + priceImpact = (quoteModel.priceImpact * HUNDRED_PERCENTS).toInt(), + notificationConfig = highPriceImpactNotificationConfig(), + ), + ) + } + return warnings + } + + private fun getSwapButtonEnabled(preparedSwapConfigState: PreparedSwapConfigState): Boolean { + return when (preparedSwapConfigState.includeFeeInAmount) { + IncludeFeeInAmount.BalanceNotEnough -> false + IncludeFeeInAmount.Excluded -> + preparedSwapConfigState.isAllowedToSpend && + preparedSwapConfigState.isBalanceEnough && + preparedSwapConfigState.isFeeEnough + is IncludeFeeInAmount.Included -> true + } + } + + fun createQuotesErrorState( + uiStateHolder: SwapStateHolder, + swapProvider: SwapProvider, + fromToken: TokenSwapInfo, + toToken: CryptoCurrencyStatus?, + dataError: DataError, + ): SwapStateHolder { + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + val warning = getWarningForError(dataError, fromToken.cryptoCurrencyStatus.currency) + val providerState = getProviderStateForError( + swapProvider = swapProvider, + fromToken = fromToken.cryptoCurrencyStatus.currency, + dataError = dataError, + onProviderClick = actions.onProviderClick, + selectionType = ProviderState.SelectionType.CLICK, + ) + val receiveCardData = toToken?.let { + SwapCardState.SwapCardData( + type = TransactionCardType.ReceiveCard(), + amountTextFieldValue = TextFieldValue( + text = "0", + ), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + token = toToken, + tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, + coinId = toToken.currency.network.backendId, + isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, + tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, + canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, + networkIconRes = uiStateHolder.receiveCardData.networkIconRes, + balance = toToken.getFormattedAmount(), + isBalanceHidden = isBalanceHiddenProvider(), + ) + } ?: SwapCardState.Empty( + type = TransactionCardType.ReceiveCard(), + amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}", + amountTextFieldValue = TextFieldValue( + text = "0", + ), + canSelectAnotherToken = true, + ) + return uiStateHolder.copy( + sendCardData = uiStateHolder.sendCardData.copy( + amountEquivalent = getFormattedFiatAmount(fromToken.amountFiat), + ), + receiveCardData = receiveCardData, + warnings = listOf(warning), + permissionState = SwapPermissionState.Empty, + fee = FeeItemState.Empty, + swapButton = SwapButton( + enabled = false, + loading = false, + onClick = actions.onSwapClick, + ), + updateInProgress = false, + providerState = providerState, + ) + } + + private fun getProviderStateForError( + swapProvider: SwapProvider, + fromToken: CryptoCurrency, + dataError: DataError, + onProviderClick: (String) -> Unit, + selectionType: ProviderState.SelectionType, + ): ProviderState { + return when (dataError) { + is DataError.ExchangeTooSmallAmountError -> { + swapProvider.convertToAvailableFromProviderState( + alertText = resourceReference( + R.string.express_provider_min_amount, + wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), + ), + selectionType = selectionType, + onProviderClick = onProviderClick, + ) + } + else -> { + ProviderState.Empty() + } + } + } + + private fun getWarningForError(dataError: DataError, fromToken: CryptoCurrency): SwapWarning { + return when (dataError) { + is DataError.ExchangeTooSmallAmountError -> SwapWarning.TooSmallAmountWarning( + notificationConfig = NotificationConfig( + title = resourceReference( + id = R.string.warning_express_too_minimal_amount_title, + formatArgs = wrappedList(dataError.amount.getFormattedCryptoAmount(fromToken)), + ), + subtitle = resourceReference(R.string.warning_express_too_minimal_amount_description), + iconResId = R.drawable.ic_alert_circle_24, + ), + ) + is DataError.UnknownError -> SwapWarning.GeneralWarning( + notificationConfig = NotificationConfig( + title = resourceReference(R.string.common_error), + subtitle = resourceReference(R.string.swapping_generic_error), + iconResId = R.drawable.img_attention_20, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_button_refresh), + onClick = actions.onRetryClick, + ), + ), + ) + else -> SwapWarning.GeneralWarning( + notificationConfig = NotificationConfig( + title = resourceReference(R.string.common_error), + subtitle = resourceReference(R.string.generic_error_code, wrappedList(dataError.code.toString())), + iconResId = R.drawable.img_attention_20, + ), + ) + } + } + fun createQuotesEmptyAmountState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, ): SwapStateHolder { + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardData( + sendCardData = SwapCardState.SwapCardData( type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard), amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = emptyAmountState.zeroAmountEquivalent, + token = uiStateHolder.sendCardData.token, tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, coinId = uiStateHolder.sendCardData.coinId, isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, + networkIconRes = uiStateHolder.sendCardData.networkIconRes, balance = emptyAmountState.fromTokenWalletBalance, isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardData( + receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountTextFieldValue = TextFieldValue("0"), amountEquivalent = emptyAmountState.zeroAmountEquivalent, + token = uiStateHolder.receiveCardData.token, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, coinId = uiStateHolder.receiveCardData.coinId, isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, + networkIconRes = uiStateHolder.receiveCardData.networkIconRes, balance = emptyAmountState.toTokenWalletBalance, isBalanceHidden = isBalanceHiddenProvider(), ), warnings = emptyList(), - fee = FeeState.Empty, + fee = FeeItemState.Empty, swapButton = SwapButton( enabled = false, loading = false, onClick = { }, ), updateInProgress = false, + providerState = ProviderState.Empty(), ) } @@ -236,15 +507,10 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: ) } - fun addTokensToState( - uiState: SwapStateHolder, - dataState: FoundTokensState, - networkInfo: NetworkInfo, - ): SwapStateHolder { + fun addTokensToState(uiState: SwapStateHolder, tokensDataState: CurrenciesGroup): SwapStateHolder { return uiState.copy( - selectTokenState = tokensDataConverter.convertWithNetwork( - value = dataState, - network = networkInfo, + selectTokenState = tokensDataConverter.convert( + value = tokensDataState, ), ) } @@ -256,6 +522,7 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } fun updateSwapAmount(uiState: SwapStateHolder, amount: String): SwapStateHolder { + if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState return uiState.copy( sendCardData = uiState.sendCardData.copy( amountTextFieldValue = TextFieldValue( @@ -267,6 +534,8 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder { + if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState + if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState val patchedSendCardData = uiState.sendCardData.copy( isBalanceHidden = isBalanceHidden, ) @@ -274,11 +543,18 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: isBalanceHidden = isBalanceHidden, ) val selectTokenState = uiState.selectTokenState?.copy( - addedTokens = uiState.selectTokenState.addedTokens.map { - it.copy( - addedTokenBalanceData = it.addedTokenBalanceData?.copy(isBalanceHidden = isBalanceHidden), - ) - }, + availableTokens = uiState.selectTokenState.availableTokens.map { + when (it) { + is TokenToSelectState.TokenToSelect -> { + it.copy( + addedTokenBalanceData = it.addedTokenBalanceData?.copy(isBalanceHidden = isBalanceHidden), + ) + } + is TokenToSelectState.Title -> { + it + } + } + }.toImmutableList(), ) return uiState.copy( @@ -289,10 +565,13 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } fun updateApproveType(uiState: SwapStateHolder, approveType: ApproveType): SwapStateHolder { - return if (uiState.permissionState is SwapPermissionState.ReadyForRequest) { + val config = uiState.bottomSheetConfig?.content as? GivePermissionBottomSheetConfig + return if (config != null) { uiState.copy( - permissionState = uiState.permissionState.copy( - approveType = approveType, + bottomSheetConfig = uiState.bottomSheetConfig.copy( + content = config.copy( + data = config.data.copy(approveType = approveType), + ), ), ) } else { @@ -300,144 +579,54 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } } - fun updateFeeSelectedItem(uiState: SwapStateHolder, item: Item, isFeeEnough: Boolean): SwapStateHolder { - val newSelectedItem = item.copy( - startText = TextReference.Res(R.string.send_network_fee_title), - ) - val permissionState = uiState.permissionState - val newPermissionState = if (permissionState is SwapPermissionState.ReadyForRequest) { - permissionState.copy( - fee = newSelectedItem.endText, - ) - } else { - permissionState - } - val updateState = when (val fee = uiState.fee) { - is FeeState.Loaded -> { - getUpdatedFeeStateForEnoughFee(uiState, fee, item, newSelectedItem, newPermissionState, isFeeEnough) - } - is FeeState.NotEnoughFundsWarning -> { - getUpdatedFeeStateForNotEnoughFee(uiState, fee, item, newSelectedItem, newPermissionState, isFeeEnough) - } - else -> uiState - } - return if (isFeeEnough) { - updateState.copy( - warnings = uiState.warnings.filterNot { it is SwapWarning.InsufficientFunds }, - ) - } else { - updateState.copy( - warnings = uiState.warnings.plus(SwapWarning.InsufficientFunds), - ) - } - } - - @Suppress("LongParameterList") - private fun getUpdatedFeeStateForEnoughFee( - uiState: SwapStateHolder, - fee: FeeState.Loaded, - itemToSelect: Item, - newSelectedItem: Item, - newPermissionState: SwapPermissionState, - isFeeEnough: Boolean, - ): SwapStateHolder { - val newState = fee.state?.copy( - selectedItem = newSelectedItem, - items = selectNewItem(fee.state.items, itemToSelect), - ) - val newFeeState = if (isFeeEnough) { - fee.copy(state = newState) - } else { - FeeState.NotEnoughFundsWarning( - tangemFee = fee.tangemFee, - state = newState, - onSelectItem = fee.onSelectItem, - ) - } + fun createInitialErrorState(uiState: SwapStateHolder, onRefreshClick: () -> Unit): SwapStateHolder { return uiState.copy( - fee = newFeeState, - permissionState = newPermissionState, - swapButton = uiState.swapButton.copy( - enabled = isFeeEnough, + warnings = listOf( + SwapWarning.GeneralWarning( + notificationConfig = NotificationConfig( + title = TextReference.Res(R.string.warning_express_refresh_required_title), + subtitle = TextReference.EMPTY, + iconResId = R.drawable.ic_alert_circle_24, + buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = TextReference.Res(R.string.warning_button_refresh), + onClick = onRefreshClick, + ), + ), + ), ), ) } - @Suppress("LongParameterList") - private fun getUpdatedFeeStateForNotEnoughFee( - uiState: SwapStateHolder, - fee: FeeState.NotEnoughFundsWarning, - itemToSelect: Item, - newSelectedItem: Item, - newPermissionState: SwapPermissionState, - isFeeEnough: Boolean, - ): SwapStateHolder { - val newState = fee.state?.copy( - selectedItem = newSelectedItem, - items = selectNewItem(fee.state.items, itemToSelect), - ) - val newFeeState = if (isFeeEnough) { - FeeState.Loaded( - tangemFee = fee.tangemFee, - state = newState, - onSelectItem = fee.onSelectItem, - ) - } else { - fee.copy(state = newState) - } - return uiState.copy( - fee = newFeeState, - permissionState = newPermissionState, - swapButton = uiState.swapButton.copy( - enabled = isFeeEnough, - ), - ) - } - - private fun createFeeState( - quoteModel: SwapState.QuotesLoadedState, - uiStateHolder: SwapStateHolder, - onFeeSetup: (TxFee) -> Unit, - ): FeeState { - val previousFeeState = when (val stateFee = uiStateHolder.fee) { - is FeeState.Loaded -> stateFee.state - is FeeState.NotEnoughFundsWarning -> stateFee.state - else -> null - } - val permissionState = quoteModel.permissionState - val feeState = if (permissionState is PermissionDataState.PermissionReadyForRequest) { - permissionState.requestApproveData.fee - } else { - quoteModel.swapDataModel?.fee - } - val selectFeeState = createSelectFeeState( - fee = feeState, - previousState = previousFeeState, - onFeeSetup = onFeeSetup, - ) - return if (quoteModel.preparedSwapConfigState.isFeeEnough) { - FeeState.Loaded( - tangemFee = quoteModel.tangemFee, - state = selectFeeState, - onSelectItem = actions.onSelectItemFee, - ) - } else { - FeeState.NotEnoughFundsWarning( - tangemFee = quoteModel.tangemFee, - state = selectFeeState, - onSelectItem = actions.onSelectItemFee, - ) - } - } - - private fun selectNewItem(items: ImmutableList>, selectItem: Item): ImmutableList> { - return items.map { - if (it.id == selectItem.id) { - it.copy(isSelected = true) - } else { - it.copy(isSelected = false) + private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState { + val isClickable: Boolean + val fee = when (txFeeState) { + TxFeeState.Empty -> return FeeItemState.Empty + is TxFeeState.SingleFeeState -> { + isClickable = false + txFeeState.fee } - }.toImmutableList() + is TxFeeState.MultipleFeeState -> { + isClickable = true + when (feeType) { + FeeType.NORMAL -> { + txFeeState.normalFee + } + FeeType.PRIORITY -> { + txFeeState.priorityFee + } + } + } + } + + return FeeItemState.Content( + feeType = feeType, + title = resourceReference(R.string.common_fee_label), + amountCrypto = fee.feeCryptoFormatted, + symbolCrypto = fee.cryptoSymbol, + amountFiatFormatted = fee.feeFiatFormatted, + isClickable = isClickable, + onClick = actions.onClickFee, + ) } fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder { @@ -447,16 +636,45 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: ) } + @Suppress("LongParameterList") fun createSuccessState( uiState: SwapStateHolder, - txState: TxState.TxSent, - onSecondaryBtnClick: () -> Unit, + timeStamp: Long, + txUrl: String, + dataState: SwapProcessDataState, + onExploreClick: () -> Unit, + onStatusClick: () -> Unit, ): SwapStateHolder { + val fee = requireNotNull(dataState.selectedFee) + val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) + val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) + val fromAmount = requireNotNull(dataState.amount?.toBigDecimal()) + val toAmount = requireNotNull(dataState.swapDataModel?.toTokenAmount?.value) + val providerState = uiState.providerState as ProviderState.Content + + val fromCryptoAmount = BigDecimalFormatter.formatCryptoAmount(fromAmount, fromCryptoCurrency.currency) + val toCryptoAmount = BigDecimalFormatter.formatCryptoAmount(toAmount, toCryptoCurrency.currency) + val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) + val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) + return uiState.copy( successState = SwapSuccessStateHolder( - fromTokenAmount = txState.fromAmount ?: "", - toTokenAmount = txState.toAmount ?: "", - onSecondaryButtonClick = onSecondaryBtnClick, + timestamp = timeStamp, + txUrl = txUrl, + providerName = stringReference(providerState.name), + providerType = stringReference(providerState.type), + showStatusButton = providerState.type == ExchangeProviderType.CEX.name, + providerIcon = providerState.iconUrl, + rate = providerState.subtitle, + fee = stringReference("${fee.feeCryptoFormatted} (${fee.feeFiatFormatted})"), + fromTokenAmount = stringReference(fromCryptoAmount), + toTokenAmount = stringReference(toCryptoAmount), + fromTokenFiatAmount = stringReference(fromFiatAmount), + toTokenFiatAmount = stringReference(toFiatAmount), + fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency), + toTokenIconState = iconStateConverter.convert(toCryptoCurrency), + onExploreButtonClick = onExploreClick, + onStatusButtonClick = onStatusClick, ), ) } @@ -472,16 +690,6 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: ) } - fun mapError(uiState: SwapStateHolder, error: DataError, onClick: () -> Unit): SwapStateHolder { - return when (error) { - // todo use if needed later - // DataError.InsufficientLiquidity -> TODO() - // DataError.NoError -> TODO() - is DataError.UnknownError -> addWarning(uiState, error.message, true, onClick) - else -> addWarning(uiState, null, false) {} - } - } - fun addAlert(uiState: SwapStateHolder, onClick: () -> Unit): SwapStateHolder { return uiState.copy( alert = SwapWarning.GenericWarning( @@ -515,7 +723,6 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: private fun convertPermissionState( lastPermissionState: SwapPermissionState, permissionDataState: PermissionDataState, - feeState: FeeState, onGivePermissionClick: () -> Unit, onChangeApproveType: (ApproveType) -> Unit, ): SwapPermissionState { @@ -524,100 +731,276 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: } else { ApproveType.UNLIMITED } - val fee = when (feeState) { - is FeeSelectState -> feeState.state?.selectedItem?.endText - else -> null - } return when (permissionDataState) { PermissionDataState.Empty -> SwapPermissionState.Empty PermissionDataState.PermissionFailed -> SwapPermissionState.Empty PermissionDataState.PermissionLoading -> SwapPermissionState.InProgress - is PermissionDataState.PermissionReadyForRequest -> SwapPermissionState.ReadyForRequest( - currency = permissionDataState.currency, - amount = permissionDataState.amount, - approveType = approveType, - walletAddress = getShortAddressValue(permissionDataState.walletAddress), - spenderAddress = getShortAddressValue(permissionDataState.spenderAddress), - fee = fee ?: TextReference.Str(""), - approveButton = ApprovePermissionButton( - enabled = true, - onClick = onGivePermissionClick, + is PermissionDataState.PermissionReadyForRequest -> { + val permissionFee = when (val fee = permissionDataState.requestApproveData.fee) { + TxFeeState.Empty -> error("Fee shouldn't be empty") + is TxFeeState.MultipleFeeState -> fee.priorityFee + is TxFeeState.SingleFeeState -> fee.fee + } + SwapPermissionState.ReadyForRequest( + currency = permissionDataState.currency, + amount = permissionDataState.amount, + approveType = approveType, + walletAddress = getShortAddressValue(permissionDataState.walletAddress), + spenderAddress = getShortAddressValue(permissionDataState.spenderAddress), + fee = TextReference.Str("${permissionFee.feeCryptoFormatted} (${permissionFee.feeFiatFormatted})"), + approveButton = ApprovePermissionButton( + enabled = true, + onClick = onGivePermissionClick, + ), + cancelButton = CancelPermissionButton( + enabled = true, + ), + onChangeApproveType = onChangeApproveType, + ) + } + } + } + + fun showPermissionBottomSheet(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder { + val permissionState = uiState.permissionState + if (permissionState is SwapPermissionState.ReadyForRequest) { + val config = GivePermissionBottomSheetConfig( + data = permissionState, + onCancel = onDismiss, + ) + return uiState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = config, ), - cancelButton = CancelPermissionButton( - enabled = true, + ) + } + return uiState + } + + fun dismissBottomSheet(uiState: SwapStateHolder): SwapStateHolder { + return uiState.copy( + bottomSheetConfig = uiState.bottomSheetConfig?.copy(isShow = false), + ) + } + + @Suppress("LongParameterList") + fun showSelectProviderBottomSheet( + uiState: SwapStateHolder, + selectedProviderId: String, + pricesLowerBest: Map, + providersStates: Map, + unavailableProviders: List, + onDismiss: () -> Unit, + ): SwapStateHolder { + val availableProvidersStates = providersStates.entries + .mapNotNull { + it.convertToProviderBottomSheetState(pricesLowerBest, actions.onProviderSelect) + } + .sortedWith(ProviderPercentDiffComparator) + val unavailableProviderStates = unavailableProviders.map { + it.convertToUnavailableProviderState( + alertText = resourceReference(R.string.express_provider_not_available), + selectionType = ProviderState.SelectionType.NONE, + ) + } + val config = ChooseProviderBottomSheetConfig( + selectedProviderId = selectedProviderId, + providers = (availableProvidersStates + unavailableProviderStates).toImmutableList(), + ) + return uiState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = config, + ), + ) + } + + fun updateProvidersBottomSheetContent( + uiState: SwapStateHolder, + tokenSwapInfoForProviders: Map, + ): SwapStateHolder { + val config = uiState.bottomSheetConfig?.content as? ChooseProviderBottomSheetConfig + return if (config != null) { + val providers = config.providers + uiState.copy( + bottomSheetConfig = uiState.bottomSheetConfig.copy( + content = config.copy( + providers = providers.map { + val tokenInfo = tokenSwapInfoForProviders[it.id] + if (it is ProviderState.Content && tokenInfo != null) { + val rateString = tokenInfo.tokenAmount + .getFormattedCryptoAmount(tokenInfo.cryptoCurrencyStatus.currency) + it.copy( + subtitle = stringReference(rateString), + ) + } else { + it + } + }.toImmutableList(), + ), ), - onChangeApproveType = onChangeApproveType, + ) + } else { + uiState + } + } + + fun updateSelectedProvider(uiState: SwapStateHolder, selectedProviderId: String): SwapStateHolder { + val config = uiState.bottomSheetConfig?.content as? ChooseProviderBottomSheetConfig + return if (config != null) { + uiState.copy( + bottomSheetConfig = uiState.bottomSheetConfig.copy( + content = config.copy( + selectedProviderId = selectedProviderId, + ), + ), + ) + } else { + uiState + } + } + + fun showSelectFeeBottomSheet( + uiState: SwapStateHolder, + selectedFee: FeeType, + txFeeState: TxFeeState.MultipleFeeState, + onDismiss: () -> Unit, + ): SwapStateHolder { + val config = ChooseFeeBottomSheetConfig( + selectedFee = selectedFee, + onSelectFeeType = { + val selectedItem = when (it) { + FeeType.NORMAL -> txFeeState.normalFee + FeeType.PRIORITY -> txFeeState.priorityFee + } + actions.onSelectFeeType.invoke(selectedItem) + }, + feeItems = txFeeState.toFeeItemState(), + ) + return uiState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismiss, + content = config, + ), + ) + } + + fun updateSelectedFeeBottomSheet(uiState: SwapStateHolder, selectedFee: FeeType): SwapStateHolder { + val config = uiState.bottomSheetConfig?.content as? ChooseFeeBottomSheetConfig + return if (config != null) { + uiState.copy( + bottomSheetConfig = uiState.bottomSheetConfig.copy( + content = config.copy( + selectedFee = selectedFee, + ), + ), + ) + } else { + uiState + } + } + + private fun TxFeeState.MultipleFeeState.toFeeItemState(): ImmutableList { + return listOf( + FeeItemState.Content( + feeType = this.normalFee.feeType, + title = resourceReference(R.string.common_fee_label), + amountCrypto = this.normalFee.feeCryptoFormatted, + symbolCrypto = this.normalFee.cryptoSymbol, + amountFiatFormatted = this.normalFee.feeFiatFormatted, + isClickable = true, + onClick = {}, + ), + FeeItemState.Content( + feeType = this.priorityFee.feeType, + title = resourceReference(R.string.common_fee_label), + amountCrypto = this.priorityFee.feeCryptoFormatted, + symbolCrypto = this.priorityFee.cryptoSymbol, + amountFiatFormatted = this.priorityFee.feeFiatFormatted, + isClickable = true, + onClick = {}, + ), + ).toImmutableList() + } + + private fun Map.Entry.convertToProviderBottomSheetState( + pricesLowerBest: Map, + onProviderSelect: (String) -> Unit, + ): ProviderState? { + val provider = this.key + return when (val state = this.value) { + is SwapState.EmptyAmountState -> null + is SwapState.QuotesLoadedState -> provider.convertToContentSelectableProviderState( + isBestRate = false, // not show best rate in bottom sheet + state = state, + onProviderClick = onProviderSelect, + pricesLowerBest = pricesLowerBest, + selectionType = ProviderState.SelectionType.SELECT, + ) + is SwapState.SwapError -> getProviderStateForError( + swapProvider = provider, + fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency, + dataError = state.error, + onProviderClick = onProviderSelect, + selectionType = ProviderState.SelectionType.SELECT, ) } } - private fun createSelectFeeState( - fee: TxFeeState?, - previousState: SelectableItemsState?, - onFeeSetup: (TxFee) -> Unit, - ): SelectableItemsState? { - if (fee == null) return null - if (previousState == null) { - onFeeSetup.invoke(fee.normalFee) // if there is no previous state, setup normal fee by default - val selectedItemId = 0 - // by default preselect normal - val preselectedItem = Item( - id = selectedItemId, - startText = TextReference.Res(R.string.send_network_fee_title), - endText = TextReference.Str(fee.normalFee.feeCryptoFormatted + fee.normalFee.feeFiatFormatted), - isSelected = true, - data = fee.normalFee, - ) - val feeItems = mutableListOf>() - val normalFeeItem = Item( - id = selectedItemId, - startText = TextReference.Res(R.string.send_fee_picker_normal), - endText = TextReference.Str(fee.normalFee.feeCryptoFormatted + fee.normalFee.feeFiatFormatted), - isSelected = true, - data = fee.normalFee, - ) - val priorityFeeItem = Item( - id = 1, - startText = TextReference.Res(R.string.send_fee_picker_priority), - endText = TextReference.Str(fee.priorityFee.feeCryptoFormatted + fee.priorityFee.feeFiatFormatted), - isSelected = false, - data = fee.priorityFee, - ) - feeItems.add(normalFeeItem) - feeItems.add(priorityFeeItem) - return SelectableItemsState( - selectedItem = preselectedItem, - items = feeItems.toImmutableList(), - ) - } else { - val normalFeeItem = - requireNotNull(previousState.items.firstOrNull()) { "in previousState there are 2 items" } - .copy( - endText = TextReference.Str(fee.normalFee.feeCryptoFormatted + fee.normalFee.feeFiatFormatted), - ) - val priorityFeeItem = - requireNotNull(previousState.items.getOrNull(1)) { "in previousState there are 2 items" } - .copy( - endText = TextReference.Str( - fee.priorityFee.feeCryptoFormatted + fee.priorityFee.feeFiatFormatted, - ), - ) - val selectedEndText = if (normalFeeItem.isSelected) { - onFeeSetup.invoke(fee.normalFee) - normalFeeItem.endText - } else { - onFeeSetup.invoke(fee.priorityFee) - priorityFeeItem.endText - } - return previousState.copy( - selectedItem = previousState.selectedItem.copy( - endText = selectedEndText, - ), - items = listOf(normalFeeItem, priorityFeeItem).toImmutableList(), - ) - } + // region warnings + private fun createPermissionNotificationConfig(fromTokenSymbol: String): NotificationConfig { + return NotificationConfig( + title = resourceReference(R.string.swapping_permission_header), + subtitle = resourceReference( + id = R.string.swapping_permission_subheader, + formatArgs = wrappedList(fromTokenSymbol), + ), + iconResId = R.drawable.ic_locked_24, + ) } + private fun highPriceImpactNotificationConfig(): NotificationConfig { + return NotificationConfig( + title = resourceReference(R.string.swapping_high_price_impact), + subtitle = resourceReference(R.string.swapping_high_price_impact_description), + iconResId = R.drawable.ic_alert_circle_24, + ) + } + + private fun createUnableToCoverFeeNotificationConfig( + fromToken: CryptoCurrency, + onBuyClick: () -> Unit, + ): NotificationConfig { + return NotificationConfig( + title = resourceReference( + R.string.warning_express_not_enough_fee_for_token_tx_title, + wrappedList(fromToken.network.name), + ), + subtitle = resourceReference( + R.string.warning_express_not_enough_fee_for_token_tx_description, + wrappedList(fromToken.network.name, fromToken.network.currencySymbol), + ), + iconResId = fromToken.networkIconResId, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.common_buy_currency, wrappedList(fromToken.network.currencySymbol)), + onClick = onBuyClick, + ), + ) + } + + private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig { + return NotificationConfig( + title = resourceReference(R.string.send_network_fee_warning_title), + subtitle = resourceReference(R.string.send_network_fee_warning_content), + iconResId = R.drawable.img_attention_20, + ) + } + // end region + private fun getShortAddressValue(fullAddress: String): String { check(fullAddress.length > ADDRESS_MIN_LENGTH) { "Invalid address" } val firstAddressPart = fullAddress.substring(startIndex = 0, endIndex = ADDRESS_FIRST_PART_LENGTH) @@ -628,11 +1011,139 @@ internal class StateBuilder(val actions: UiActions, val isBalanceHiddenProvider: return "$firstAddressPart...$secondAddressPart" } + @Suppress("LongParameterList") + private fun SwapProvider.convertToContentClickableProviderState( + isBestRate: Boolean, + fromTokenInfo: TokenSwapInfo, + toTokenInfo: TokenSwapInfo, + selectionType: ProviderState.SelectionType, + isNeedBadge: Boolean, + onProviderClick: (String) -> Unit, + ): ProviderState { + val rate = toTokenInfo.tokenAmount.value.calculateRate( + fromTokenInfo.tokenAmount.value, + toTokenInfo.cryptoCurrencyStatus.currency.decimals, + ) + val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol + val toCurrencySymbol = toTokenInfo.cryptoCurrencyStatus.currency.symbol + val rateString = "1 $fromCurrencySymbol ≈ $rate $toCurrencySymbol" + val badge = if (isNeedBadge && isBestRate) { + ProviderState.AdditionalBadge.BestTrade + } else { + ProviderState.AdditionalBadge.Empty + } + return ProviderState.Content( + id = this.providerId, + name = this.name, + iconUrl = this.imageLarge, + type = this.type.toString(), + subtitle = stringReference(rateString), + additionalBadge = badge, + selectionType = selectionType, + percentLowerThenBest = ZERO_PERCENT, + onProviderClick = onProviderClick, + ) + } + + private fun SwapProvider.convertToContentSelectableProviderState( + isBestRate: Boolean, + state: SwapState.QuotesLoadedState, + selectionType: ProviderState.SelectionType, + pricesLowerBest: Map, + onProviderClick: (String) -> Unit, + ): ProviderState { + val toTokenInfo = state.toTokenInfo + val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency) + val additionalBadge = if (state.permissionState is PermissionDataState.PermissionReadyForRequest) { + ProviderState.AdditionalBadge.PermissionRequired + } else if (isBestRate) { + ProviderState.AdditionalBadge.BestTrade + } else { + ProviderState.AdditionalBadge.Empty + } + return ProviderState.Content( + id = this.providerId, + name = this.name, + iconUrl = this.imageLarge, + type = this.type.toString(), + subtitle = stringReference(rateString), + additionalBadge = additionalBadge, + selectionType = selectionType, + percentLowerThenBest = pricesLowerBest[this] ?: ZERO_PERCENT, + onProviderClick = onProviderClick, + ) + } + + private fun SwapProvider.convertToUnavailableProviderState( + alertText: TextReference, + selectionType: ProviderState.SelectionType, + onProviderClick: ((String) -> Unit)? = null, + ): ProviderState { + return ProviderState.Unavailable( + id = this.providerId, + name = this.name, + iconUrl = this.imageLarge, + type = this.type.toString(), + selectionType = selectionType, + alertText = alertText, + onProviderClick = onProviderClick, + ) + } + + private fun SwapProvider.convertToAvailableFromProviderState( + alertText: TextReference, + selectionType: ProviderState.SelectionType, + onProviderClick: (String) -> Unit, + ): ProviderState { + return ProviderState.Content( + id = this.providerId, + name = this.name, + iconUrl = this.imageLarge, + type = this.type.toString(), + selectionType = selectionType, + subtitle = alertText, + additionalBadge = ProviderState.AdditionalBadge.Empty, + percentLowerThenBest = ZERO_PERCENT, + onProviderClick = onProviderClick, + ) + } + + private fun CryptoCurrencyStatus.getFormattedAmount(): String { + val amount = value.amount ?: return UNKNOWN_AMOUNT_SIGN + + return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals) + } + + @Suppress("UnusedPrivateMember") + private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String { + val fiatAmount = value.fiatAmount ?: return UNKNOWN_AMOUNT_SIGN + val appCurrency = appCurrencyProvider() + + return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol) + } + + private fun getFormattedFiatAmount(amount: BigDecimal?): String { + val appCurrency = appCurrencyProvider() + + return BigDecimalFormatter.formatFiatAmount(amount, appCurrency.code, appCurrency.symbol) + } + + private fun SwapAmount.getFormattedCryptoAmount(token: CryptoCurrency): String { + return "${this.formatToUIRepresentation()} ${token.network.currencySymbol}" + } + + private fun BigDecimal.calculateRate(to: BigDecimal, decimals: Int): BigDecimal { + return this.divide(to, min(decimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP) + } + private companion object { const val ADDRESS_MIN_LENGTH = 11 const val ADDRESS_FIRST_PART_LENGTH = 7 const val ADDRESS_SECOND_PART_LENGTH = 4 private const val PRICE_IMPACT_THRESHOLD = 0.1 private const val HUNDRED_PERCENTS = 100 + private const val UNKNOWN_AMOUNT_SIGN = "—" + private const val MAX_DECIMALS_TO_SHOW = 8 + private const val ZERO_PERCENT = 0f } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheetContent.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheet.kt similarity index 87% rename from features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheetContent.kt rename to features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheet.kt index 6f8bee6fe7..fdf6c22c6d 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheetContent.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapPermissionBottomSheet.kt @@ -12,7 +12,8 @@ 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.* -import com.tangem.core.ui.components.atoms.Hand +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @@ -20,12 +21,21 @@ import com.tangem.feature.swap.models.ApprovePermissionButton import com.tangem.feature.swap.models.ApproveType import com.tangem.feature.swap.models.CancelPermissionButton import com.tangem.feature.swap.models.SwapPermissionState +import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.ImmutableList @Composable -fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, onCancel: () -> Unit) { +fun SwapPermissionBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet(config) { content: GivePermissionBottomSheetConfig -> + SwapPermissionBottomSheetContent(content = content) + } +} + +@Composable +private fun SwapPermissionBottomSheetContent(content: GivePermissionBottomSheetConfig) { var isPermissionAlertShow by remember { mutableStateOf(false) } + val data = content.data Column( modifier = Modifier .background(color = TangemTheme.colors.background.primary) @@ -33,10 +43,6 @@ fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, .padding(horizontal = TangemTheme.dimens.spacing16), horizontalAlignment = Alignment.CenterHorizontally, ) { - Hand() - - SpacerH10() - Box(modifier = Modifier.fillMaxWidth()) { Text( modifier = Modifier.align(Alignment.Center), @@ -87,7 +93,7 @@ fun SwapPermissionBottomSheetContent(data: SwapPermissionState.ReadyForRequest, text = stringResource(id = R.string.common_cancel), modifier = Modifier.fillMaxWidth(), onClick = { - onCancel() + content.onCancel() }, ) @@ -261,7 +267,7 @@ private fun DropdownSelector( @Composable private fun FeeItem(fee: String) { InformationItem( - subtitle = stringResource(id = R.string.send_fee_label), + subtitle = stringResource(id = R.string.common_fee_label), value = fee, ) } @@ -288,7 +294,7 @@ private fun getTitleForApproveType(approveType: ApproveType): String = when (app @Composable private fun Preview_AgreementBottomSheet_InLightTheme() { TangemTheme(isDark = false) { - SwapPermissionBottomSheetContent(data = previewData) {} + SwapPermissionBottomSheetContent(content = previewData) } } @@ -296,20 +302,21 @@ private fun Preview_AgreementBottomSheet_InLightTheme() { @Composable private fun Preview_AgreementBottomSheet_InDarkTheme() { TangemTheme(isDark = true) { - SwapPermissionBottomSheetContent(data = previewData) {} + SwapPermissionBottomSheetContent(content = previewData) } } -private val previewData = SwapPermissionState.ReadyForRequest( - currency = "DAI", - amount = "∞", - walletAddress = "", - spenderAddress = "", - fee = TextReference.Str("2,14$"), - approveType = ApproveType.UNLIMITED, - approveButton = ApprovePermissionButton(true) {}, - cancelButton = CancelPermissionButton(true), - onChangeApproveType = { ApproveType.UNLIMITED }, -) - -//endregion preview \ No newline at end of file +private val previewData = GivePermissionBottomSheetConfig( + data = SwapPermissionState.ReadyForRequest( + currency = "DAI", + amount = "∞", + walletAddress = "", + spenderAddress = "", + fee = TextReference.Str("2,14$"), + approveType = ApproveType.UNLIMITED, + approveButton = ApprovePermissionButton(true) {}, + cancelButton = CancelPermissionButton(true), + onChangeApproveType = { ApproveType.UNLIMITED }, + ), + onCancel = {}, +) \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index 850d4434df..a70b6ce6f2 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -1,99 +1,42 @@ package com.tangem.feature.swap.ui import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.padding import androidx.compose.material.* +import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.swap.models.SwapPermissionState import com.tangem.feature.swap.models.SwapStateHolder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch +import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig +import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig +import com.tangem.feature.swap.models.states.GivePermissionBottomSheetConfig -@OptIn(ExperimentalMaterialApi::class) @Composable internal fun SwapScreen(stateHolder: SwapStateHolder) { - val coroutineScope = rememberCoroutineScope() - val bottomSheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = true, - ) - BackHandler( - onBack = { - if (bottomSheetState.isVisible) { - hideBottomSheet(coroutineScope, stateHolder, bottomSheetState) - } else { - stateHolder.onBackClicked() - } - }, - ) + BackHandler(onBack = stateHolder.onBackClicked) - LaunchedEffect(bottomSheetState.targetValue) { - if (bottomSheetState.targetValue == ModalBottomSheetValue.Hidden) { - stateHolder.onCancelPermissionBottomSheet.invoke() + Scaffold( + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + + SwapScreenContent( + state = stateHolder, + modifier = Modifier.padding(scaffoldPaddings), + ) + + stateHolder.bottomSheetConfig?.let { config -> + when (config.content) { + is GivePermissionBottomSheetConfig -> { + SwapPermissionBottomSheet(config = config) + } + is ChooseProviderBottomSheetConfig -> { + ChooseProviderBottomSheet(config = config) + } + is ChooseFeeBottomSheetConfig -> { + ChooseFeeBottomSheet(config = config) + } + } } } - - ModalBottomSheetLayout( - modifier = Modifier.systemBarsPadding(), - sheetContent = { - if (stateHolder.permissionState is SwapPermissionState.ReadyForRequest) { - SwapPermissionBottomSheetContent( - data = stateHolder.permissionState, - onCancel = { - hideBottomSheet(coroutineScope, stateHolder, bottomSheetState) - }, - ) - } else { - // Required "else" block to prevent compose crash - // always close BS if its empty, cause user should not see this - LaunchedEffect(Unit) { - coroutineScope.launch { bottomSheetState.hide() } - } - Box(modifier = Modifier.fillMaxSize()) - } - }, - sheetState = bottomSheetState, - sheetShape = RoundedCornerShape( - topStart = TangemTheme.dimens.radius16, - topEnd = TangemTheme.dimens.radius16, - ), - sheetElevation = TangemTheme.dimens.elevation24, - scrimColor = TangemTheme.colors.background.secondary.copy(alpha = 0.4f), - content = { - SwapScreenContent( - state = stateHolder, - onPermissionWarningClick = { - val isBottomSheetReady = !bottomSheetState.isVisible && - stateHolder.permissionState is SwapPermissionState.ReadyForRequest - coroutineScope.launch { - if (isBottomSheetReady) { - bottomSheetState.show() - stateHolder.onShowPermissionBottomSheet.invoke() - } else { - bottomSheetState.hide() - } - } - }, - ) - }, - ) -} - -@OptIn(ExperimentalMaterialApi::class) -private fun hideBottomSheet( - coroutineScope: CoroutineScope, - stateHolder: SwapStateHolder, - bottomSheetState: ModalBottomSheetState, -) { - coroutineScope.launch { - stateHolder.onCancelPermissionBottomSheet.invoke() - bottomSheetState.hide() - } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 703f7bb82c..949ead0f64 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -7,10 +7,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue @@ -20,45 +18,34 @@ import androidx.constraintlayout.compose.ConstraintLayout import com.tangem.common.Strings.STARS import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.components.states.Item -import com.tangem.core.ui.components.states.SelectableItemsState -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.getActiveIconResByCoinId +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.swap.domain.models.ui.FeeType -import com.tangem.feature.swap.domain.models.ui.TxFee import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.FeeItemState +import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.presentation.R -import kotlinx.collections.immutable.toImmutableList -import java.math.BigDecimal @Suppress("LongMethod") @Composable -internal fun SwapScreenContent(state: SwapStateHolder, onPermissionWarningClick: () -> Unit) { +internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) { val keyboard by keyboardAsState() Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(color = TangemTheme.colors.background.secondary), ) { - Image( - modifier = Modifier - .size(width = TangemTheme.dimens.size164, height = TangemTheme.dimens.size80) - .align(Alignment.BottomCenter) - .padding(bottom = TangemTheme.dimens.spacing28), - painter = painterResource(id = R.drawable.ill_one_inch_powered), - contentDescription = null, - contentScale = ContentScale.Fit, - ) - Column { AppBarWithBackButton( text = stringResource(R.string.common_swap), onBackClick = state.onBackClicked, iconRes = R.drawable.ic_close_24, ) + Column( modifier = Modifier .fillMaxWidth() @@ -74,11 +61,20 @@ internal fun SwapScreenContent(state: SwapStateHolder, onPermissionWarningClick: ) { MainInfo(state) - FeeItem(feeState = state.fee, currency = state.networkCurrency) + ProviderItemBlock( + state = state.providerState, + modifier = Modifier + .clickable( + enabled = state.providerState.onProviderClick != null, + onClick = { state.providerState.onProviderClick?.invoke(state.providerState.id) }, + ), + ) + + FeeItemBlock(state = state.fee) if (state.warnings.isNotEmpty()) SwapWarnings(warnings = state.warnings) - if (state.permissionState is SwapPermissionState.InProgress) { + AnimatedVisibility(visible = state.permissionState is SwapPermissionState.InProgress) { CardWithIcon( title = stringResource(id = R.string.swapping_pending_transaction_title), description = stringResource(id = R.string.swapping_pending_transaction_subtitle), @@ -92,7 +88,7 @@ internal fun SwapScreenContent(state: SwapStateHolder, onPermissionWarningClick: }, ) } - MainButton(state = state, onPermissionWarningClick = onPermissionWarningClick) + MainButton(state = state, onPermissionWarningClick = state.onShowPermissionBottomSheet) } } @@ -137,53 +133,24 @@ private fun MainInfo(state: SwapStateHolder) { ConstraintLayout( modifier = Modifier.fillMaxWidth(), ) { - val networkIconRes = remember { - getActiveIconRes(state.blockchainId) - } val (topCard, bottomCard, button) = createRefs() val priceImpactWarning = state.warnings.filterIsInstance().firstOrNull() - TransactionCard( - type = state.sendCardData.type, - balance = if (state.sendCardData.isBalanceHidden) { - STARS - } else { - state.sendCardData.balance - }, - textFieldValue = state.sendCardData.amountTextFieldValue, - amountEquivalent = state.sendCardData.amountEquivalent, - tokenIconUrl = state.sendCardData.tokenIconUrl, - tokenCurrency = state.sendCardData.tokenCurrency, - priceImpact = priceImpactWarning, - networkIconRes = if (state.sendCardData.isNotNativeToken) networkIconRes else null, - iconPlaceholder = state.sendCardData.coinId?.let { - getActiveIconResByCoinId(it) - }, - onChangeTokenClick = if (state.sendCardData.canSelectAnotherToken) state.onSelectTokenClick else null, + TransactionCardData( + priceImpactWarning = priceImpactWarning, + swapCardState = state.sendCardData, modifier = Modifier.constrainAs(topCard) { top.linkTo(parent.top) }, + onSelectTokenClick = state.onSelectTokenClick, ) val marginCard = TangemTheme.dimens.spacing16 - TransactionCard( - type = state.receiveCardData.type, - balance = if (state.receiveCardData.isBalanceHidden) STARS else state.receiveCardData.balance, - textFieldValue = state.receiveCardData.amountTextFieldValue, - amountEquivalent = state.receiveCardData.amountEquivalent, - tokenIconUrl = state.receiveCardData.tokenIconUrl, - tokenCurrency = state.receiveCardData.tokenCurrency, - priceImpact = priceImpactWarning, - networkIconRes = if (state.receiveCardData.isNotNativeToken) networkIconRes else null, - iconPlaceholder = state.receiveCardData.coinId?.let { - getActiveIconResByCoinId(it) - }, - onChangeTokenClick = if (state.receiveCardData.canSelectAnotherToken) { - state.onSelectTokenClick - } else { - null - }, + TransactionCardData( + priceImpactWarning = priceImpactWarning, + swapCardState = state.receiveCardData, modifier = Modifier.constrainAs(bottomCard) { top.linkTo(topCard.bottom, margin = marginCard) }, + onSelectTokenClick = state.onSelectTokenClick, ) val marginButton = TangemTheme.dimens.spacing32 SwapButton( @@ -197,6 +164,47 @@ private fun MainInfo(state: SwapStateHolder) { } } +@Composable +private fun TransactionCardData( + priceImpactWarning: SwapWarning.HighPriceImpact?, + swapCardState: SwapCardState, + onSelectTokenClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + when (swapCardState) { + is SwapCardState.Empty -> { + TransactionCardEmpty( + type = swapCardState.type, + amountEquivalent = swapCardState.amountEquivalent, + textFieldValue = swapCardState.amountTextFieldValue, + onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, + modifier = modifier, + ) + } + is SwapCardState.SwapCardData -> { + TransactionCard( + type = swapCardState.type, + balance = if (swapCardState.isBalanceHidden) { + STARS + } else { + swapCardState.balance + }, + textFieldValue = swapCardState.amountTextFieldValue, + amountEquivalent = swapCardState.amountEquivalent, + tokenIconUrl = swapCardState.tokenIconUrl ?: "", + tokenCurrency = swapCardState.tokenCurrency, + priceImpact = priceImpactWarning, + networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null, + iconPlaceholder = swapCardState.coinId?.let { + getActiveIconResByCoinId(it) + }, + onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, + modifier = modifier, + ) + } + } +} + @OptIn(ExperimentalMaterialApi::class) @Composable private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) { @@ -228,48 +236,7 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) { } } -@Composable -private fun FeeItem(feeState: FeeState, currency: String) { - val titleString = stringResource(id = R.string.send_network_fee_title) - val disclaimer = stringResource(id = R.string.swapping_tangem_fee_disclaimer, "${feeState.tangemFee}%") - when (feeState) { - is FeeState.Loaded -> { - if (feeState.state != null) { - SelectableInfoCard( - state = feeState.state, - disclaimer = disclaimer, - onSelect = feeState.onSelectItem, - ) - } - } - FeeState.Loading -> { - SmallInfoCardWithDisclaimer( - startText = titleString, - endText = "", - disclaimer = disclaimer, - isLoading = true, - ) - } - is FeeState.NotEnoughFundsWarning -> { - if (feeState.state != null) { - SelectableInfoCardWithWarning( - state = feeState.state, - warningText = stringResource( - id = R.string.swapping_not_enough_funds_for_fee, - currency, - currency, - ), - disclaimer = disclaimer, - onSelect = feeState.onSelectItem, - ) - } - } - is FeeState.Empty -> { - SmallInfoCard(startText = titleString, endText = "") - } - } -} - +@Suppress("LongMethod") @Composable private fun SwapWarnings(warnings: List) { Column( @@ -280,26 +247,14 @@ private fun SwapWarnings(warnings: List) { warnings.forEach { warning -> when (warning) { is SwapWarning.HighPriceImpact -> { - WarningCard( - title = stringResource(id = R.string.swapping_high_price_impact), - description = stringResource(id = R.string.swapping_high_price_impact_description), + Notification( + config = warning.notificationConfig, + iconTint = TangemTheme.colors.icon.warning, ) } is SwapWarning.PermissionNeeded -> { - WarningCard( - title = stringResource(id = R.string.swapping_permission_header), - description = stringResource( - id = R.string.swapping_permission_subheader, - warning.tokenCurrency, - ), - icon = { - Icon( - painter = painterResource(id = com.tangem.core.ui.R.drawable.ic_locked_24), - contentDescription = null, - modifier = Modifier.size(TangemTheme.dimens.size20), - tint = TangemTheme.colors.icon.primary1, - ) - }, + Notification( + config = warning.notificationConfig, ) } is SwapWarning.GenericWarning -> { @@ -316,14 +271,28 @@ private fun SwapWarnings(warnings: List) { onClick = warning.onClick, ) } + is SwapWarning.NoAvailableTokensToSwap -> { + Notification( + config = warning.notificationConfig, + ) + } + is SwapWarning.TooSmallAmountWarning -> { + Notification( + config = warning.notificationConfig, + iconTint = TangemTheme.colors.icon.warning, + ) + } + is SwapWarning.UnableToCoverFeeWarning -> { + Notification( + config = warning.notificationConfig, + ) + } + is SwapWarning.GeneralWarning -> { + Notification( + config = warning.notificationConfig, + ) + } else -> {} - // is SwapWarning.RateExpired -> { - // RefreshableWaringCard( - // title = stringResource(id = R.string.), - // description = stringResource(id = R.string.), - // onClick = warning.onClick, - // ) - // } } SpacerH8() } @@ -367,7 +336,7 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U // region preview -private val sendCard = SwapCardData( +private val sendCard = SwapCardState.SwapCardData( type = TransactionCardType.SendCard({}) {}, amountTextFieldValue = TextFieldValue(), amountEquivalent = "1 000 000", @@ -377,10 +346,12 @@ private val sendCard = SwapCardData( canSelectAnotherToken = false, balance = "123", coinId = "", + token = null, + networkIconRes = R.drawable.img_polygon_22, isBalanceHidden = false, ) -private val receiveCard = SwapCardData( +private val receiveCard = SwapCardState.SwapCardData( type = TransactionCardType.ReceiveCard(), amountTextFieldValue = TextFieldValue(), amountEquivalent = "1 000 000", @@ -390,63 +361,39 @@ private val receiveCard = SwapCardData( canSelectAnotherToken = true, balance = "33333", coinId = "", + token = null, + networkIconRes = R.drawable.img_polygon_22, isBalanceHidden = false, ) -val stateSelectable = SelectableItemsState( - selectedItem = Item( - 0, - TextReference.Str("Balance"), - TextReference.Str("0.4405434 BTC"), - true, - TxFee( - feeValue = BigDecimal.ZERO, - gasLimit = 0, - feeFiatFormatted = "", - feeCryptoFormatted = "", - feeType = FeeType.NORMAL, - ), - ), - items = listOf( - Item( - 0, - TextReference.Str("Normal"), - TextReference.Str("0.4405434 BTC"), - true, - TxFee( - feeValue = BigDecimal.ZERO, - gasLimit = 0, - feeFiatFormatted = "", - feeCryptoFormatted = "", - feeType = FeeType.NORMAL, - ), - ), - Item( - 1, - TextReference.Str("Priority"), - TextReference.Str("0.46 BTC"), - false, - TxFee( - feeValue = BigDecimal.ZERO, - gasLimit = 0, - feeFiatFormatted = "", - feeCryptoFormatted = "", - feeType = FeeType.NORMAL, - ), - ), - ).toImmutableList(), -) - private val state = SwapStateHolder( - networkId = "ethereum", sendCardData = sendCard, receiveCardData = receiveCard, - fee = FeeState.Loaded( - tangemFee = 0.0, - state = stateSelectable, - onSelectItem = {}, + fee = FeeItemState.Content( + feeType = FeeType.NORMAL, + title = stringReference("Fee"), + amountCrypto = "100", + symbolCrypto = "1000", + amountFiatFormatted = "(100)", + isClickable = true, + onClick = {}, + ), + warnings = listOf( + SwapWarning.PermissionNeeded( + notificationConfig = NotificationConfig( + title = stringReference("Give Premission"), + subtitle = stringReference("To continue swapping you need to give permission to Tangem"), + iconResId = R.drawable.ic_locked_24, + ), + ), + SwapWarning.NoAvailableTokensToSwap( + notificationConfig = NotificationConfig( + title = stringReference("No tokens"), + subtitle = stringReference("Swap tokens not available"), + iconResId = R.drawable.img_attention_20, + ), + ), ), - warnings = listOf(SwapWarning.PermissionNeeded("DAI")), networkCurrency = "MATIC", swapButton = SwapButton(enabled = true, loading = false, onClick = {}), onRefresh = {}, @@ -454,13 +401,14 @@ private val state = SwapStateHolder( onChangeCardsClicked = {}, permissionState = SwapPermissionState.InProgress, blockchainId = "POLYGON", + providerState = ProviderState.Loading(), ) @Preview @Composable private fun SwapScreenContentPreview() { TangemTheme(isDark = false) { - SwapScreenContent(state = state) {} + SwapScreenContent(state = state, modifier = Modifier) } } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 80d9af4c8a..82b05a7c86 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -1,55 +1,52 @@ package com.tangem.feature.swap.ui -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Divider import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity 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 coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest import com.tangem.common.Strings -import com.tangem.core.ui.components.CircleShimmer -import com.tangem.core.ui.components.CurrencyPlaceholderIcon -import com.tangem.core.ui.components.SpacerW2 +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.ExpandableSearchView -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.core.ui.components.currency.tokenicon.TokenIcon +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +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.utils.ImageBackgroundContrastChecker -import com.tangem.feature.swap.models.Network -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenBalanceData -import com.tangem.feature.swap.models.TokenToSelect +import com.tangem.feature.swap.models.* import com.tangem.feature.swap.presentation.R -import kotlinx.coroutines.launch +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList @Composable fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { + BackHandler(onBack = onBack) + Scaffold( modifier = Modifier .systemBarsPadding() .background(color = TangemTheme.colors.background.secondary), content = { padding -> - ListOfTokens(state = state, Modifier.padding(padding)) + val modifier = Modifier.padding(padding) + if (state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty()) { + EmptyTokensList(modifier) + } else { + ListOfTokens(state = state, modifier = modifier) + } }, topBar = { ExpandableSearchView( @@ -58,61 +55,90 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) placeholderSearchText = stringResource(id = R.string.common_search_tokens), onSearchChange = state.onSearchEntered, onSearchDisplayClose = { state.onSearchEntered("") }, - subtitle = state.network.name, - icon = painterResource(id = getActiveIconRes(state.network.blockchainId)), + subtitle = stringResource(id = R.string.express_exchange_token_list_subtitle), ) }, ) } -@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun EmptyTokensList(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillMaxSize(), + ) { + Column(modifier = Modifier.align(Alignment.Center)) { + Image( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.ic_no_token_44), + colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), + contentDescription = null, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.exchange_tokens_empty_tokens), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + } +} + @Composable private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) { val screenBackgroundColor = TangemTheme.colors.background.secondary LazyColumn( modifier = modifier .background(color = screenBackgroundColor) - .fillMaxWidth(), + .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (state.addedTokens.isNotEmpty()) { - stickyHeader { Header(title = R.string.swapping_token_list_your_tokens) } - } + item { SpacerH8() } - itemsIndexed(items = state.addedTokens) { index, item -> - TokenItem( - token = item, - network = state.network, - screenBackgroundColor = screenBackgroundColor, - onTokenClick = { - state - .onTokenSelected(item.id) - }, - ) + tokensToSelectItems(state.availableTokens, state.onTokenSelected) - if (index != state.addedTokens.lastIndex) { - Divider( - color = TangemTheme.colors.stroke.primary, - startIndent = TangemTheme.dimens.spacing54, + item { SpacerH12() } + + tokensToSelectItems(state.unavailableTokens, state.onTokenSelected) + + item { SpacerH12() } + } +} + +private fun LazyListScope.tokensToSelectItems( + items: ImmutableList, + onTokenClick: (String) -> Unit, +) { + itemsIndexed(items = items) { index, item -> + when (item) { + is TokenToSelectState.Title -> { + TitleHeader( + item = item, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = items.lastIndex, + ), ) } - } - - if (state.otherTokens.isNotEmpty()) { - stickyHeader { Header(title = R.string.swapping_token_list_other_tokens) } - } - - itemsIndexed(items = state.otherTokens) { index, item -> - TokenItem( - token = item, - network = state.network, - screenBackgroundColor = screenBackgroundColor, - onTokenClick = { state.onTokenSelected(item.id) }, - ) - if (index != state.otherTokens.lastIndex) { - Divider( - color = TangemTheme.colors.stroke.primary, - startIndent = TangemTheme.dimens.spacing54, + is TokenToSelectState.TokenToSelect -> { + TokenItem( + token = item, + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = items.lastIndex, + ) + .background(TangemTheme.colors.background.action), + onTokenClick = { + onTokenClick(item.id) + }, ) } } @@ -120,28 +146,39 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = } @Composable -private fun Header(@StringRes title: Int) { - Text( - text = stringResource(id = title).uppercase(), - style = TangemTheme.typography.overline, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier +private fun TitleHeader(item: TokenToSelectState.Title, modifier: Modifier = Modifier) { + Box( + modifier = modifier .fillMaxWidth() - .padding( - vertical = TangemTheme.dimens.spacing6, - horizontal = TangemTheme.dimens.spacing16, - ), - textAlign = TextAlign.Start, - ) + .background(TangemTheme.colors.background.action), + ) { + Text( + text = item.title.resolveReference().uppercase(), + style = TangemTheme.typography.overline, + modifier = Modifier + .padding( + top = TangemTheme.dimens.spacing16, + start = TangemTheme.dimens.spacing16, + ), + ) + } } @Suppress("LongMethod") @Composable -private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundColor: Color, onTokenClick: () -> Unit) { +private fun TokenItem( + token: TokenToSelectState.TokenToSelect, + onTokenClick: () -> Unit, + modifier: Modifier = Modifier, +) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .clickable(onClick = onTokenClick) + .height(TangemTheme.dimens.size72) + .clickable( + enabled = token.available, + onClick = onTokenClick, + ) .padding( vertical = TangemTheme.dimens.spacing14, horizontal = TangemTheme.dimens.spacing16, @@ -149,12 +186,15 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo verticalAlignment = Alignment.CenterVertically, ) { TokenIcon( - token = token, - screenBackgroundColor = screenBackgroundColor, - iconPlaceholder = if (token.isNative) getActiveIconRes(network.blockchainId) else null, + state = token.tokenIcon, + shouldDisplayNetwork = true, ) - Column(modifier = Modifier.align(Alignment.CenterVertically)) { + Column( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = TangemTheme.dimens.spacing12), + ) { Text( text = token.name, style = TangemTheme.typography.subtitle1, @@ -174,13 +214,7 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo Spacer(modifier = Modifier.weight(1f)) - if (!token.available) { - Text( - text = stringResource(id = R.string.swapping_token_not_available), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - } else if (token.addedTokenBalanceData != null) { + if (token.addedTokenBalanceData != null) { Column( horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Center, @@ -195,7 +229,11 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo token.addedTokenBalanceData.amountEquivalent.orEmpty() }, style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, + color = if (token.available) { + TangemTheme.colors.text.primary1 + } else { + TangemTheme.colors.text.tertiary + }, ) SpacerW2() Text( @@ -214,73 +252,16 @@ private fun TokenItem(token: TokenToSelect, network: Network, screenBackgroundCo } } -@Suppress("MagicNumber") -@Composable -private fun TokenIcon(token: TokenToSelect, screenBackgroundColor: Color, @DrawableRes iconPlaceholder: Int?) { - var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) } - var isBackgroundColorDefined by remember { mutableStateOf(false) } - val isDarkTheme = isSystemInDarkTheme() - val coroutineScope = rememberCoroutineScope() - - val data = token.iconUrl.ifEmpty { - iconPlaceholder - } - Box( - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing12) - .background( - color = iconBackgroundColor, - shape = TangemTheme.shapes.roundedCorners8, - ), - ) { - val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size40.roundToPx() } - val iconModifier = Modifier.size(TangemTheme.dimens.size40) - - val colorFilter = if (!token.available) { - val matrix = ColorMatrix().apply { setToSaturation(0f) } - ColorFilter.colorMatrix(matrix) - } else { - null - } - - SubcomposeAsyncImage( - modifier = iconModifier, - model = ImageRequest.Builder(LocalContext.current) - .data(data) - .size(size = pixelsSize) - .memoryCacheKey(key = data.toString() + pixelsSize) - .crossfade(true) - .allowHardware(false) - .listener( - onSuccess = { _, result -> - if (!isBackgroundColorDefined && isDarkTheme) { - coroutineScope.launch { - val color = ImageBackgroundContrastChecker( - drawable = result.drawable, - backgroundColor = screenBackgroundColor.toArgb(), - size = pixelsSize, - ).getContrastColor(isDarkTheme = true) - iconBackgroundColor = color - isBackgroundColorDefined = true - } - } - }, - ).build(), - contentDescription = token.id, - loading = { CircleShimmer(modifier = iconModifier) }, - error = { CurrencyPlaceholderIcon(modifier = iconModifier, id = token.id) }, - alpha = if (!token.available) 0.7f else 1f, - colorFilter = colorFilter, - ) - } -} - -private val token = TokenToSelect( +private val token = TokenToSelectState.TokenToSelect( + tokenIcon = TokenIconState.CoinIcon( + url = "", + fallbackResId = 0, + isGrayscale = false, + showCustomBadge = false, + ), id = "", name = "USDC", symbol = "USDC", - iconUrl = "", - isNative = false, addedTokenBalanceData = TokenBalanceData( amount = "15 000 $", amountEquivalent = "15 000 " + @@ -289,17 +270,30 @@ private val token = TokenToSelect( ), ) +private val title = TokenToSelectState.Title( + title = stringReference("MY TOKENS"), +) + @Preview @Composable private fun TokenScreenPreview() { - SwapSelectTokenScreen( - state = SwapSelectTokenStateHolder( - addedTokens = listOf(token, token, token), - otherTokens = listOf(token, token, token), - onSearchEntered = {}, - onTokenSelected = {}, - network = Network("Ethereum", "ETH"), - ), - onBack = {}, - ) + TangemTheme(isDark = false) { + SwapSelectTokenScreen( + state = SwapSelectTokenStateHolder( + availableTokens = listOf(title, token, token, token).toImmutableList(), + unavailableTokens = listOf(title, token, token, token).toImmutableList(), + onSearchEntered = {}, + onTokenSelected = {}, + ), + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun EmptyTokensListPreview() { + TangemTheme(isDark = false) { + EmptyTokensList() + } } \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index a05fbdb29b..d540514a81 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -1,18 +1,24 @@ package com.tangem.feature.swap.ui -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.ResultScreenContent +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.components.inputrow.InputRowBestRate +import com.tangem.core.ui.components.inputrow.InputRowDefault +import com.tangem.core.ui.components.inputrow.InputRowImage +import com.tangem.core.ui.components.transactions.TransactionDoneTitle +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.models.SwapSuccessStateHolder import com.tangem.feature.swap.presentation.R @@ -21,41 +27,118 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) { Scaffold( modifier = Modifier.systemBarsPadding(), content = { padding -> - ResultScreenContent( - resultMessage = makeSuccessMessage( - fromTokenAmount = state.fromTokenAmount, - toTokenAmount = state.toTokenAmount, - ), - resultColor = TangemTheme.colors.icon.attention, - onButtonClick = onBack, - icon = R.drawable.ic_clock_24, - secondaryButtonIcon = R.drawable.ic_arrow_top_right_24, - onSecondaryButtonClick = state.onSecondaryButtonClick, - secondaryButtonText = R.string.swapping_success_view_explorer_button_title, - title = R.string.swapping_success_view_title, - modifier = Modifier.padding(padding), - ) + SwapSuccessScreenContent(padding = padding, state = state) }, topBar = { AppBarWithBackButton( - text = stringResource(R.string.common_swap), onBackClick = onBack, iconRes = R.drawable.ic_close_24, ) }, + bottomBar = { + SwapSuccessScreenButtons( + textRes = R.string.common_close, + txUrl = state.txUrl, + showStatusButton = state.showStatusButton, + onExploreClick = state.onExploreButtonClick, + onStatusClick = state.onStatusButtonClick, + onDoneClick = onBack, + ) + }, ) } @Composable -private fun makeSuccessMessage(fromTokenAmount: String, toTokenAmount: String): AnnotatedString { - val swapToString = stringResource(id = R.string.swapping_swap_of_to, fromTokenAmount) - val message = "$swapToString\n$toTokenAmount" - return buildAnnotatedString { - append(message) - addStyle( - style = SpanStyle(color = TangemTheme.colors.text.accent), - start = message.indexOf(toTokenAmount), - end = message.length, +private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: PaddingValues) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(TangemTheme.colors.background.secondary) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) { + TransactionDoneTitle(titleRes = R.string.swapping_success_view_title, date = state.timestamp) + SpacerH16() + InputRowImage( + title = TextReference.Res(R.string.swapping_success_from_title), + subtitle = state.fromTokenAmount, + caption = state.fromTokenFiatAmount, + tokenIconState = state.fromTokenIconState ?: TokenIconState.Loading, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + SpacerH16() + InputRowImage( + title = TextReference.Res(R.string.swapping_success_to_title), + subtitle = state.toTokenAmount, + caption = state.toTokenFiatAmount, + tokenIconState = state.toTokenIconState ?: TokenIconState.Loading, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + SpacerH16() + InputRowBestRate( + imageUrl = state.providerIcon, + title = state.providerName, + titleExtra = state.providerType, + subtitle = state.rate, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + SpacerH16() + InputRowDefault( + title = TextReference.Res(R.string.common_fee_label), + text = state.fee, + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun SwapSuccessScreenButtons( + @StringRes textRes: Int, + txUrl: String, + showStatusButton: Boolean, + onExploreClick: () -> Unit, + onStatusClick: () -> Unit, + onDoneClick: () -> Unit, +) { + Column( + modifier = Modifier + .background(TangemTheme.colors.background.secondary) + .padding(TangemTheme.dimens.spacing16), + ) { + if (txUrl.isNotBlank()) { + Row { + SecondaryButtonIconStart( + text = stringResource(id = R.string.common_explore), + iconResId = R.drawable.ic_web_24, + onClick = onExploreClick, + modifier = Modifier.weight(1f), + ) + if (showStatusButton) { + SpacerW12() + SecondaryButtonIconStart( + text = stringResource(id = R.string.express_cex_status_button_title), + iconResId = R.drawable.ic_arrow_top_right_24, + onClick = onStatusClick, + modifier = Modifier.weight(1f), + ) + } + } + SpacerH12() + } + PrimaryButton( + text = stringResource(id = textRes), + enabled = true, + onClick = onDoneClick, + modifier = Modifier.fillMaxWidth(), ) } } @@ -63,9 +146,23 @@ private fun makeSuccessMessage(fromTokenAmount: String, toTokenAmount: String): // region preview private val state = SwapSuccessStateHolder( - fromTokenAmount = "1 000 DAI", - toTokenAmount = "1 131,46 MATIC", -) {} + timestamp = 0L, + txUrl = "https://www.google.com/#q=nam", + fee = TextReference.Str("1 000 DAI ~ 1 000 MATIC"), + providerName = TextReference.Str("1inch"), + providerType = TextReference.Str(ExchangeProviderType.DEX.name), + showStatusButton = false, + providerIcon = "", + fromTokenAmount = TextReference.Str("1 000 DAI"), + toTokenAmount = TextReference.Str("1 000 MATIC"), + fromTokenFiatAmount = TextReference.Str("1 000 $"), + toTokenFiatAmount = TextReference.Str("1 000 $"), + fromTokenIconState = TokenIconState.Loading, + toTokenIconState = TokenIconState.Loading, + rate = TextReference.Str("1 000 DAI ~ 1 000 MATIC"), + onExploreButtonClick = {}, + onStatusButtonClick = {}, +) @Preview(showBackground = true) @Composable diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 3cbd35a23f..b16a5e4c21 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap.ui import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -9,7 +10,6 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -17,6 +17,7 @@ import androidx.compose.material.ripple.rememberRipple 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.toArgb import androidx.compose.ui.platform.LocalContext @@ -55,53 +56,114 @@ fun TransactionCard( @DrawableRes networkIconRes: Int? = null, onChangeTokenClick: (() -> Unit)? = null, ) { - Card( - shape = RoundedCornerShape(TangemTheme.dimens.radius12), - backgroundColor = TangemTheme.colors.background.primary, - elevation = TangemTheme.dimens.elevation2, - modifier = modifier, + Box( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius12), + color = TangemTheme.colors.background.primary, + ) + .fillMaxSize(), ) { - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start, - ) { - Header(balance = balance, type = type) + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + ) { + Header(balance = stringResource(R.string.common_balance, balance), type = type) - Content( - type = type, - amountEquivalent = amountEquivalent, - textFieldValue = textFieldValue, - priceImpact = priceImpact, - ) - } + Content( + type = type, + amountEquivalent = amountEquivalent, + textFieldValue = textFieldValue, + priceImpact = priceImpact, + ) + } - Box(modifier = Modifier.align(Alignment.BottomEnd)) { - Token( - tokenIconUrl = tokenIconUrl, - tokenCurrency = tokenCurrency, - networkIconRes = networkIconRes, - iconPlaceholder = iconPlaceholder, - ) - } + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + Token( + tokenIconUrl = tokenIconUrl, + tokenCurrency = tokenCurrency, + networkIconRes = networkIconRes, + iconPlaceholder = iconPlaceholder, + ) + } - if (onChangeTokenClick != null) { - Box(modifier = Modifier.align(Alignment.CenterEnd)) { - ChangeTokenSelector() - } - Box( - Modifier - .align(Alignment.CenterEnd) - .height(TangemTheme.dimens.size116) - .width(TangemTheme.dimens.size102) - .clickable( - indication = rememberRipple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - ) { onChangeTokenClick() }, - ) + if (onChangeTokenClick != null) { + Box(modifier = Modifier.align(Alignment.CenterEnd)) { + ChangeTokenSelector() } + Box( + Modifier + .align(Alignment.CenterEnd) + .height(TangemTheme.dimens.size116) + .width(TangemTheme.dimens.size102) + .clickable( + indication = rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + ) { onChangeTokenClick() }, + ) + } + } +} + +@Composable +fun TransactionCardEmpty( + type: TransactionCardType, + amountEquivalent: String?, + textFieldValue: TextFieldValue?, + modifier: Modifier = Modifier, + onChangeTokenClick: (() -> Unit)? = null, +) { + Box( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius12), + color = TangemTheme.colors.background.primary, + ) + .fillMaxSize(), + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + ) { + Header( + balance = stringResource(id = R.string.swapping_token_not_available), + type = type, + ) + + Content( + type = type, + amountEquivalent = amountEquivalent, + textFieldValue = textFieldValue, + priceImpact = null, + ) + } + + Box(modifier = Modifier.align(Alignment.BottomEnd)) { + Token( + tokenIconUrl = "", + tokenCurrency = "", + iconPlaceholder = R.drawable.ic_no_token_44, + ) + } + + if (onChangeTokenClick != null) { + Box(modifier = Modifier.align(Alignment.CenterEnd)) { + ChangeTokenSelector() + } + Box( + Modifier + .align(Alignment.CenterEnd) + .height(TangemTheme.dimens.size116) + .width(TangemTheme.dimens.size102) + .clickable( + indication = rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + ) { onChangeTokenClick() }, + ) } } } @@ -133,14 +195,16 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie ) SpacerW16() if (balance.isNotBlank()) { - Text( - text = stringResource(R.string.common_balance, balance), - color = TangemTheme.colors.text.tertiary, - style = MaterialTheme.typography.body2, - modifier = Modifier - .defaultMinSize(minHeight = TangemTheme.dimens.size20) - .padding(top = TangemTheme.dimens.spacing2), - ) + AnimatedContent(targetState = balance, label = "") { + Text( + text = it, + color = TangemTheme.colors.text.tertiary, + style = MaterialTheme.typography.body2, + modifier = Modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size20) + .padding(top = TangemTheme.dimens.spacing2), + ) + } } else { RectangleShimmer( modifier = Modifier @@ -231,12 +295,14 @@ private fun Content( ) } } else { - Text( - text = amountEquivalent, - color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography.body2, - modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20), - ) + AnimatedContent(targetState = amountEquivalent, label = "") { + Text( + text = it, + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20), + ) + } } } else { RectangleShimmer( @@ -311,6 +377,7 @@ private fun TokenIcon( color = iconBackgroundColor, shape = TangemTheme.shapes.roundedCorners8, ) + .clip(TangemTheme.shapes.roundedCorners8) val data = tokenIconUrl.ifEmpty { iconPlaceholder } @@ -376,6 +443,7 @@ fun ChangeTokenSelector() { Icon( modifier = Modifier.size(TangemTheme.dimens.size20), painter = painterResource(id = R.drawable.ic_chevron_24), + tint = TangemTheme.colors.icon.secondary, contentDescription = null, ) } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt index 5b4b806568..7b5bd446fa 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapProcessDataState.kt @@ -1,16 +1,31 @@ package com.tangem.feature.swap.viewmodels -import com.tangem.feature.swap.domain.models.domain.Currency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData -import com.tangem.feature.swap.domain.models.ui.SwapStateData +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress import com.tangem.feature.swap.domain.models.ui.TxFee +import com.tangem.feature.swap.models.ApproveType data class SwapProcessDataState( + // Initial network id val networkId: String, - val fromCurrency: Currency? = null, - val toCurrency: Currency? = null, + val fromCryptoCurrency: CryptoCurrencyStatus? = null, + val toCryptoCurrency: CryptoCurrencyStatus? = null, + // Amount from input val amount: String? = null, val approveDataModel: RequestApproveStateData? = null, - val swapDataModel: SwapStateData? = null, - val selectedFee: TxFee? = null, -) \ No newline at end of file + val approveType: ApproveType? = null, + val swapDataModel: SwapDataModel? = null, + val selectedFee: TxFee? = null, // todo + val tokensDataState: TokensDataStateExpress? = null, + val selectedProvider: SwapProvider? = null, + val lastLoadedSwapStates: Map = emptyMap(), +) { + + fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? { + return lastLoadedSwapStates[selectedProvider] as? SwapState.QuotesLoadedState + } +} \ No newline at end of file diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index 4740549f50..b2ba4e8a24 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt @@ -4,42 +4,49 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.* +import arrow.core.getOrElse +import arrow.core.mapNotNull import com.tangem.common.Provider import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.utils.InputNumberFormatter +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.tokens.model.Network +import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.domain.BlockchainInteractor import com.tangem.feature.swap.domain.SwapInteractor -import com.tangem.feature.swap.domain.models.domain.Currency +import com.tangem.feature.swap.domain.models.DataError import com.tangem.feature.swap.domain.models.domain.PermissionOptions +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.formatToUIRepresentation import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.feature.swap.models.SwapPermissionState -import com.tangem.feature.swap.models.SwapStateHolder -import com.tangem.feature.swap.models.UiActions -import com.tangem.feature.swap.models.toDomainApproveType +import com.tangem.feature.swap.models.* import com.tangem.feature.swap.presentation.SwapFragment import com.tangem.feature.swap.router.SwapNavScreen import com.tangem.feature.swap.router.SwapRouter import com.tangem.feature.swap.ui.StateBuilder -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.Debouncer -import com.tangem.utils.coroutines.runCatching +import com.tangem.utils.coroutines.* +import com.tangem.utils.isNullOrZero import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import timber.log.Timber +import java.math.BigDecimal +import java.math.RoundingMode import java.text.DecimalFormat import java.text.NumberFormat import java.util.Locale import javax.inject.Inject +import kotlin.math.absoluteValue import kotlin.properties.Delegates +typealias SuccessLoadedSwapData = Map + @Suppress("LargeClass", "LongParameterList") @HiltViewModel internal class SwapViewModel @Inject constructor( @@ -48,34 +55,36 @@ internal class SwapViewModel @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver { - private val currency = Json.decodeFromString( - savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY] - ?: error("no expected parameter Currency found"), - ) - private val derivationPath = savedStateHandle.get(SwapFragment.DERIVATION_PATH) - private val network = savedStateHandle.get(SwapFragment.NETWORK) + private val initialCryptoCurrency: CryptoCurrency = savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY] + ?: error("no expected parameter CryptoCurrency found`") + private lateinit var initialCryptoCurrencyStatus: CryptoCurrencyStatus private var isBalanceHidden = true + private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + private val stateBuilder = StateBuilder( actions = createUiActions(), isBalanceHiddenProvider = Provider { isBalanceHidden }, + appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), ) private val inputNumberFormatter = InputNumberFormatter(NumberFormat.getInstance(Locale.getDefault()) as DecimalFormat) private val amountDebouncer = Debouncer() - private val singleTaskScheduler = SingleTaskScheduler() + private val singleTaskScheduler = SingleTaskScheduler>() - private var dataState by mutableStateOf(SwapProcessDataState(networkId = currency.networkId)) + private var dataState by mutableStateOf(SwapProcessDataState(networkId = initialCryptoCurrency.network.backendId)) var uiState: SwapStateHolder by mutableStateOf( stateBuilder.createInitialLoadingState( - initialCurrency = currency, - networkInfo = blockchainInteractor.getBlockchainInfo(currency.networkId), + initialCurrency = initialCryptoCurrency, + networkInfo = blockchainInteractor.getBlockchainInfo(initialCryptoCurrency.network.backendId), ), ) private set @@ -88,8 +97,20 @@ internal class SwapViewModel @Inject constructor( get() = swapRouter.currentScreen init { - swapInteractor.initDerivationPathAndNetwork(derivationPath, network) - initTokens(currency) + viewModelScope.launch(dispatchers.io) { + swapInteractor.getSelectedWallet()?.let { + initialCryptoCurrencyStatus = + requireNotNull(getCryptoCurrencyStatusUseCase(it.walletId, initialCryptoCurrency.id).getOrNull()) { + "Failed to get initial crypto currency status" + } + + swapInteractor.initDerivationPathAndNetwork( + derivationPath = initialCryptoCurrency.network.derivationPath.value, + network = initialCryptoCurrency.network, + ) + initTokens() + } + } } override fun onCreate(owner: LifecycleOwner) { @@ -110,16 +131,15 @@ internal class SwapViewModel @Inject constructor( } fun onScreenOpened() { - analyticsEventHandler.send(SwapEvents.SwapScreenOpened(currency.symbol)) + analyticsEventHandler.send(SwapEvents.SwapScreenOpened(initialCryptoCurrency.symbol)) } fun setRouter(router: SwapRouter) { swapRouter = router uiState = uiState.copy( - onBackClicked = router::back, onSelectTokenClick = { router.openScreen(SwapNavScreen.SelectToken) - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened) + sendSelectTokenScreenOpenedEvent() }, onSuccess = { router.openScreen(SwapNavScreen.Success) @@ -127,60 +147,124 @@ internal class SwapViewModel @Inject constructor( ) } - private fun initTokens(currency: Currency) { + private fun sendSelectTokenScreenOpenedEvent() { + val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() ?: false + val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() ?: false + val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(availableTokens = isAnyAvailableTokens)) + } + + private fun initTokens() { viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { - swapInteractor.initTokensToSwap(currency) + swapInteractor.getTokensDataState(initialCryptoCurrency) + }.onSuccess { state -> + updateTokensState(state) + applyInitialTokenChoice( + state, + swapInteractor.selectInitialCurrencyToSwap( + initialCryptoCurrency, + state, + ), + ) + }.onFailure { + Timber.tag(loggingTag).e(it) + + applyInitialTokenChoice( + state = TokensDataStateExpress.EMPTY, + selectedCurrency = null, + ) + + uiState = stateBuilder.createInitialErrorState(uiState) { + uiState = stateBuilder.createInitialLoadingState( + initialCurrency = initialCryptoCurrency, + networkInfo = blockchainInteractor.getBlockchainInfo(initialCryptoCurrency.network.backendId), + ) + initTokens() + } } - .onSuccess { state -> - dataState = dataState.copy( - fromCurrency = state.preselectTokens.fromToken, - toCurrency = state.preselectTokens.toToken, - ) - updateTokensState(dataState = state.foundTokensState) - startLoadingQuotes( - fromToken = state.preselectTokens.fromToken, - toToken = state.preselectTokens.toToken, - amount = lastAmount.value, - ) - } - .onFailure { - Timber.e(it) - } } } - private fun updateTokensState(dataState: FoundTokensState) { + private fun applyInitialTokenChoice(state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?) { + val fromCurrencyStatus = initialCryptoCurrencyStatus + dataState = dataState.copy( + fromCryptoCurrency = fromCurrencyStatus, + toCryptoCurrency = selectedCurrency, + tokensDataState = state, + ) + if (selectedCurrency == null) { + analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap) + uiState = stateBuilder.createNoAvailableTokensToSwapState( + uiStateHolder = uiState, + fromToken = fromCurrencyStatus, + ) + } else { + startLoadingQuotes( + fromToken = fromCurrencyStatus, + toToken = selectedCurrency, + amount = lastAmount.value, + toProvidersList = findSwapProviders(fromCurrencyStatus, selectedCurrency), + ) + } + } + + private fun updateTokensState(dataState: TokensDataStateExpress) { + val tokensDataState = if (!isOrderReversed) dataState.toGroup else dataState.fromGroup uiState = stateBuilder.addTokensToState( uiState = uiState, - dataState = dataState, - networkInfo = blockchainInteractor.getBlockchainInfo(currency.networkId), + tokensDataState = tokensDataState, ) } - private fun startLoadingQuotes(fromToken: Currency, toToken: Currency, amount: String) { + private fun startLoadingQuotes( + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + amount: String, + toProvidersList: List, + isSilent: Boolean = false, + ) { singleTaskScheduler.cancelTask() - uiState = stateBuilder.createQuotesLoadingState(uiState, fromToken, toToken, currency.id) + if (!isSilent) { + uiState = stateBuilder.createQuotesLoadingState( + uiState, + fromToken.currency, + toToken.currency, + initialCryptoCurrency.id.value, + ) + } singleTaskScheduler.scheduleTask( viewModelScope, loadQuotesTask( fromToken = fromToken, toToken = toToken, amount = amount, + toProvidersList = toProvidersList, ), ) } - private fun startLoadingQuotesFromLastState() { - val fromCurrency = dataState.fromCurrency - val toCurrency = dataState.toCurrency + private fun startLoadingQuotesFromLastState(isSilent: Boolean = false) { + val fromCurrency = dataState.fromCryptoCurrency + val toCurrency = dataState.toCryptoCurrency val amount = dataState.amount if (fromCurrency != null && toCurrency != null && amount != null) { - startLoadingQuotes(fromCurrency, toCurrency, amount) + startLoadingQuotes( + fromToken = fromCurrency, + toToken = toCurrency, + amount = amount, + isSilent = isSilent, + toProvidersList = findSwapProviders(fromCurrency, toCurrency), + ) } } - private fun loadQuotesTask(fromToken: Currency, toToken: Currency, amount: String): PeriodicTask { + private fun loadQuotesTask( + fromToken: CryptoCurrencyStatus, + toToken: CryptoCurrencyStatus, + amount: String, + toProvidersList: List, + ): PeriodicTask> { return PeriodicTask( UPDATE_DELAY, task = { @@ -195,35 +279,25 @@ internal class SwapViewModel @Inject constructor( networkId = dataState.networkId, fromToken = fromToken, toToken = toToken, + providers = toProvidersList, amountToSwap = amount, selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, ) } }, - onSuccess = { swapState -> - when (swapState) { - is SwapState.QuotesLoadedState -> { - fillDataState(swapState.permissionState, swapState.swapDataModel) - uiState = stateBuilder.createQuotesLoadedState( - uiStateHolder = uiState, - quoteModel = swapState, - fromToken = fromToken, - ) { updatedFee -> - dataState = dataState.copy( - selectedFee = updatedFee, - ) - } - } - is SwapState.EmptyAmountState -> { - uiState = stateBuilder.createQuotesEmptyAmountState( - uiStateHolder = uiState, - emptyAmountState = swapState, - ) - } - is SwapState.SwapError -> { - Timber.e("SwapError when loading quotes ${swapState.error}") - uiState = stateBuilder.mapError(uiState, swapState.error) { startLoadingQuotesFromLastState() } - } + onSuccess = { providersState -> + if (providersState.isNotEmpty()) { + val (provider, state) = updateLoadedQuotes(providersState) + setupLoadedState(provider, state, fromToken) + uiState = stateBuilder.updateProvidersBottomSheetContent( + uiState = uiState, + tokenSwapInfoForProviders = providersState + .getLastLoadedSuccessStates() + .entries + .associate { it.key.providerId to it.value.toTokenInfo }, + ) + } else { + Timber.e("Accidentally empty quotes list") } }, onError = { @@ -233,48 +307,180 @@ internal class SwapViewModel @Inject constructor( ) } - private fun fillDataState(permissionState: PermissionDataState, swapDataModel: SwapStateData?) { + private fun setupLoadedState(provider: SwapProvider, state: SwapState, fromToken: CryptoCurrencyStatus) { + when (state) { + is SwapState.QuotesLoadedState -> { + fillLoadedDataState(state, state.permissionState, state.swapDataModel) + val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() + val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId + uiState = stateBuilder.createQuotesLoadedState( + uiStateHolder = uiState, + quoteModel = state, + fromToken = fromToken.currency, + swapProvider = provider, + bestRatedProviderId = bestRatedProviderId, + isManyProviders = dataState.lastLoadedSwapStates.size > 1, + selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL, + ) + if (uiState.warnings.any { it is SwapWarning.UnableToCoverFeeWarning }) { + analyticsEventHandler.send( + SwapEvents.NoticeNotEnoughFee( + token = initialCryptoCurrency.symbol, + blockchain = fromToken.currency.network.name, + ), + ) + } + } + is SwapState.EmptyAmountState -> { + uiState = stateBuilder.createQuotesEmptyAmountState( + uiStateHolder = uiState, + emptyAmountState = state, + ) + } + is SwapState.SwapError -> { + if (state.error is DataError.UnknownError) { + singleTaskScheduler.cancelTask() + } + uiState = stateBuilder.createQuotesErrorState( + uiStateHolder = uiState, + swapProvider = provider, + fromToken = state.fromTokenInfo, + toToken = dataState.toCryptoCurrency, + dataError = state.error, + ) + sendErrorAnalyticsEvent(state.error, provider) + } + } + } + + private fun sendErrorAnalyticsEvent(error: DataError, provider: SwapProvider) { + analyticsEventHandler.send( + SwapEvents.NoticeProviderError( + token = initialCryptoCurrency.symbol, + provider = provider, + errorCode = error.code, + ), + ) + } + + private fun updateLoadedQuotes(state: Map): Pair { + val nonEmptyStates = state.filter { it.value !is SwapState.EmptyAmountState } + val selectedSwapProvider = if (nonEmptyStates.isNotEmpty()) { + selectProvider(state) + } else { + null + } + dataState = dataState.copy( + selectedProvider = selectedSwapProvider, + lastLoadedSwapStates = state, + ) + selectedSwapProvider?.let { + return nonEmptyStates.entries.first { it.key == selectedSwapProvider }.toPair() + } + return state.entries.first().toPair() + } + + private fun selectProvider(state: Map): SwapProvider { + val stateSuccess = state.getLastLoadedSuccessStates() + return if (stateSuccess.isNotEmpty()) { + val currentSelected = dataState.selectedProvider + if (currentSelected != null && state.keys.contains(currentSelected)) { + currentSelected + } else { + findBestQuoteProvider(stateSuccess) ?: stateSuccess.keys.first() + } + } else { + state.keys.first() + } + } + + private fun fillLoadedDataState( + state: SwapState.QuotesLoadedState, + permissionState: PermissionDataState, + swapDataModel: SwapDataModel?, + ) { dataState = if (permissionState is PermissionDataState.PermissionReadyForRequest) { dataState.copy( approveDataModel = permissionState.requestApproveData, + approveType = dataState.approveType ?: ApproveType.UNLIMITED, ) } else { dataState.copy( swapDataModel = swapDataModel, - selectedFee = swapDataModel?.fee?.normalFee, + selectedFee = selectDefaultFee(state), ) } } + private fun selectDefaultFee(state: SwapState.QuotesLoadedState): TxFee? { + return dataState.selectedFee + ?: when (val txFee = state.txFee) { + TxFeeState.Empty -> null + is TxFeeState.MultipleFeeState -> { + txFee.normalFee + } + is TxFeeState.SingleFeeState -> { + txFee.fee + } + } + } + + @Suppress("LongMethod") private fun onSwapClick() { singleTaskScheduler.cancelTask() uiState = stateBuilder.createSwapInProgressState(uiState) + val provider = requireNotNull(dataState.selectedProvider) { "Selected provider is null" } + val lastLoadedQuotesState = dataState.lastLoadedSwapStates[provider] as? SwapState.QuotesLoadedState + if (lastLoadedQuotesState == null) { + Timber.e("Last loaded quotes state is null") + return + } viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { swapInteractor.onSwap( + swapProvider = provider, networkId = dataState.networkId, - swapStateData = requireNotNull(dataState.swapDataModel), - currencyToSend = requireNotNull(dataState.fromCurrency), - currencyToGet = requireNotNull(dataState.toCurrency), + swapData = dataState.swapDataModel, + currencyToSend = requireNotNull(dataState.fromCryptoCurrency), + currencyToGet = requireNotNull(dataState.toCryptoCurrency), amountToSwap = requireNotNull(dataState.amount), + includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = requireNotNull(dataState.selectedFee), ) } .onSuccess { when (it) { is TxState.TxSent -> { - uiState = stateBuilder.createSuccessState(uiState, it) { - val txHash = it.txAddress - if (txHash.isNotEmpty()) { - swapRouter.openUrl( - blockchainInteractor.getExplorerTransactionLink( - networkId = dataState.networkId, - txAddress = it.txAddress, - ), + val url = blockchainInteractor.getExplorerTransactionLink( + networkId = dataState.networkId, + txAddress = it.txAddress, + ) + uiState = stateBuilder.createSuccessState( + uiState = uiState, + timeStamp = it.timestamp, + dataState = dataState, + txUrl = url, + onExploreClick = { + val txHash = it.txAddress + if (txHash.isNotEmpty()) { + swapRouter.openUrl(url) + } + analyticsEventHandler.send( + event = SwapEvents.ButtonExplore(initialCryptoCurrency.symbol), ) - } - } - analyticsEventHandler.send(SwapEvents.SwapInProgressScreen) + }, + onStatusClick = { + val txExternalUrl = it.txExternalUrl + if (!txExternalUrl.isNullOrBlank()) { + swapRouter.openUrl(txExternalUrl) + analyticsEventHandler.send( + event = SwapEvents.ButtonStatus(initialCryptoCurrency.symbol), + ) + } + }, + ) + sendSuccessEvent() + swapRouter.openScreen(SwapNavScreen.Success) } is TxState.UserCancelled -> { @@ -289,31 +495,56 @@ internal class SwapViewModel @Inject constructor( } } .onFailure { + Timber.e(it) startLoadingQuotesFromLastState() makeDefaultAlert() } } } + private fun sendSuccessEvent() { + val provider = dataState.selectedProvider ?: return + val fee = dataState.selectedFee?.feeType ?: return + val sendToken = dataState.fromCryptoCurrency?.currency?.symbol ?: return + val toToken = dataState.toCryptoCurrency?.currency?.symbol ?: return + + analyticsEventHandler.send( + SwapEvents.SwapInProgressScreen( + provider = provider, + commission = fee, + sendToken = sendToken, + receiveToken = toToken, + ), + ) + } + private fun givePermissionsToSwap() { viewModelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { + val feeForPermission = when (val fee = dataState.approveDataModel?.fee) { + TxFeeState.Empty -> error("Fee should not be Empty") + is TxFeeState.MultipleFeeState -> fee.priorityFee + is TxFeeState.SingleFeeState -> fee.fee + null -> error("Fee should not be null") + } swapInteractor.givePermissionToSwap( networkId = dataState.networkId, permissionOptions = PermissionOptions( approveData = requireNotNull(dataState.approveDataModel) { "dataState.approveDataModel might not be null" }, - forTokenContractAddress = (dataState.fromCurrency as? Currency.NonNativeToken)?.contractAddress + forTokenContractAddress = (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Token) + ?.contractAddress ?: "", - fromToken = requireNotNull(dataState.fromCurrency) { + fromToken = requireNotNull(dataState.fromCryptoCurrency?.currency) { "dataState.fromCurrency might not be null" }, - approveType = requireNotNull(uiState.permissionState as? SwapPermissionState.ReadyForRequest) { - "uiState.permissionState should be SwapPermissionState.ReadyForRequest" - }.approveType.toDomainApproveType(), - txFee = requireNotNull(dataState.selectedFee) { - "dataState.selectedFee shouldn't be null" + approveType = requireNotNull(dataState.approveType) { + "uiState.permissionState should not be null" + }.toDomainApproveType(), + txFee = feeForPermission, + spenderAddress = requireNotNull(dataState.approveDataModel?.spenderAddress) { + "dataState.approveDataModel.spenderAddress shouldn't be null" }, ), ) @@ -322,6 +553,7 @@ internal class SwapViewModel @Inject constructor( when (it) { is TxState.TxSent -> { uiState = stateBuilder.loadingPermissionState(uiState) + uiState = stateBuilder.dismissBottomSheet(uiState) } is TxState.UserCancelled -> Unit else -> { @@ -338,80 +570,126 @@ internal class SwapViewModel @Inject constructor( } private fun onSearchEntered(searchQuery: String) { - viewModelScope.launch(dispatchers.main) { - runCatching(dispatchers.io) { - swapInteractor.searchTokens(dataState.networkId, searchQuery) + viewModelScope.launch(dispatchers.io) { + val tokenDataState = dataState.tokensDataState ?: return@launch + val group = if (isOrderReversed) { + tokenDataState.fromGroup + } else { + tokenDataState.toGroup } - .onSuccess { - updateTokensState(it) - } - .onFailure { } + val available = group.available.filter { + it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) + } + val unavailable = group.unavailable.filter { + it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) + } + val filteredTokenDataState = if (isOrderReversed) { + tokenDataState.copy( + fromGroup = tokenDataState.fromGroup.copy( + available = available, + unavailable = unavailable, + ), + ) + } else { + tokenDataState.copy( + toGroup = tokenDataState.toGroup.copy( + available = available, + unavailable = unavailable, + ), + ) + } + updateTokensState(filteredTokenDataState) } } private fun onTokenSelect(id: String) { - val foundToken = swapInteractor.findTokenById(id) - - analyticsEventHandler.send( - event = SwapEvents.SearchTokenClicked(currencySymbol = foundToken?.symbol), - ) + val tokens = dataState.tokensDataState ?: return + val foundToken = if (isOrderReversed) { + tokens.fromGroup.available.firstOrNull { + it.currencyStatus.currency.id.value == id + } + } else { + tokens.toGroup.available.firstOrNull { + it.currencyStatus.currency.id.value == id + } + } + foundToken?.currencyStatus?.currency?.symbol?.let { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it)) + } if (foundToken != null) { - val fromToken: Currency - val toToken: Currency + val fromToken: CryptoCurrencyStatus + val toToken: CryptoCurrencyStatus if (isOrderReversed) { - fromToken = foundToken - toToken = currency + fromToken = foundToken.currencyStatus + toToken = initialCryptoCurrencyStatus } else { - fromToken = currency - toToken = foundToken + fromToken = initialCryptoCurrencyStatus + toToken = foundToken.currencyStatus } dataState = dataState.copy( - fromCurrency = fromToken, - toCurrency = toToken, + fromCryptoCurrency = fromToken, + toCryptoCurrency = toToken, + selectedProvider = null, + ) + startLoadingQuotes( + fromToken = fromToken, + toToken = toToken, + amount = lastAmount.value, + toProvidersList = findSwapProviders(fromToken, toToken), ) - startLoadingQuotes(fromToken, toToken, lastAmount.value) swapRouter.openScreen(SwapNavScreen.Main) + updateTokensState(tokens) } } private fun onChangeCardsClicked() { - val newFromToken = dataState.toCurrency - val newToToken = dataState.fromCurrency + val newFromToken = dataState.toCryptoCurrency + val newToToken = dataState.fromCryptoCurrency if (newFromToken != null && newToToken != null) { dataState = dataState.copy( - fromCurrency = newFromToken, - toCurrency = newToToken, + fromCryptoCurrency = newFromToken, + toCryptoCurrency = newToToken, ) isOrderReversed = !isOrderReversed - val decimals = blockchainInteractor.getTokenDecimals(newFromToken) + val decimals = newFromToken.currency.decimals lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value) uiState = stateBuilder.updateSwapAmount( uiState, inputNumberFormatter.formatWithThousands(lastAmount.value, decimals), ) - startLoadingQuotes(newFromToken, newToToken, lastAmount.value) + startLoadingQuotes( + fromToken = newFromToken, + toToken = newToToken, + amount = lastAmount.value, + toProvidersList = findSwapProviders(newFromToken, newToToken), + ) } } private fun onAmountChanged(value: String) { - val fromToken = dataState.fromCurrency - val toToken = dataState.toCurrency + val fromToken = dataState.fromCryptoCurrency + val toToken = dataState.toCryptoCurrency if (fromToken != null && toToken != null) { - val decimals = blockchainInteractor.getTokenDecimals(fromToken) + val decimals = fromToken.currency.decimals val cutValue = cutAmountWithDecimals(decimals, value) lastAmount.value = cutValue uiState = stateBuilder.updateSwapAmount(uiState, inputNumberFormatter.formatWithThousands(cutValue, decimals)) amountDebouncer.debounce(viewModelScope, DEBOUNCE_AMOUNT_DELAY) { - startLoadingQuotes(fromToken, toToken, lastAmount.value) + startLoadingQuotes( + fromToken = fromToken, + toToken = toToken, + amount = lastAmount.value, + toProvidersList = findSwapProviders(fromToken, toToken), + ) } } } private fun onMaxAmountClicked() { - dataState.fromCurrency?.let { - val balance = swapInteractor.getTokenBalance(currency.networkId, it) + dataState.fromCryptoCurrency?.let { + val balance = swapInteractor.getTokenBalance(it) onAmountChanged(balance.formatToUIRepresentation()) } } @@ -433,6 +711,7 @@ internal class SwapViewModel @Inject constructor( } } + @Suppress("LongMethod", "CyclomaticComplexMethod") private fun createUiActions(): UiActions { return UiActions( onSearchEntered = { onSearchEntered(it) }, @@ -440,8 +719,8 @@ internal class SwapViewModel @Inject constructor( onAmountChanged = { onAmountChanged(it) }, onSwapClick = { onSwapClick() - val sendTokenSymbol = dataState.fromCurrency?.symbol - val receiveTokenSymbol = dataState.toCurrency?.symbol + val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol + val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol if (sendTokenSymbol != null && receiveTokenSymbol != null) { analyticsEventHandler.send( SwapEvents.ButtonSwapClicked( @@ -453,8 +732,8 @@ internal class SwapViewModel @Inject constructor( }, onGivePermissionClick = { givePermissionsToSwap() - val sendTokenSymbol = dataState.fromCurrency?.symbol - val receiveTokenSymbol = dataState.toCurrency?.symbol + val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol + val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol if (sendTokenSymbol != null && receiveTokenSymbol != null) { analyticsEventHandler.send( SwapEvents.ButtonPermissionApproveClicked( @@ -468,42 +747,193 @@ internal class SwapViewModel @Inject constructor( onChangeCardsClicked() analyticsEventHandler.send(SwapEvents.ButtonSwipeClicked) }, - onBackClicked = { onSearchEntered("") }, + onBackClicked = { + val bottomSheet = uiState.bottomSheetConfig + if (bottomSheet != null && bottomSheet.isShow) { + uiState = stateBuilder.dismissBottomSheet(uiState) + } else { + if (swapRouter.currentScreen == SwapNavScreen.SelectToken) { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = false)) + } + swapRouter.back() + } + onSearchEntered("") + }, onMaxAmountSelected = { onMaxAmountClicked() }, openPermissionBottomSheet = { singleTaskScheduler.cancelTask() analyticsEventHandler.send(SwapEvents.ButtonGivePermissionClicked) - }, - hidePermissionBottomSheet = { - startLoadingQuotesFromLastState() - analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked) + uiState = stateBuilder.showPermissionBottomSheet(uiState) { + startLoadingQuotesFromLastState(isSilent = true) + analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked) + uiState = stateBuilder.dismissBottomSheet(uiState) + } }, onAmountSelected = { onAmountSelected(it) }, onChangeApproveType = { approveType -> uiState = stateBuilder.updateApproveType(uiState, approveType) + dataState = dataState.copy(approveType = approveType) }, - onSelectItemFee = { feeItem -> - dataState = dataState.copy(selectedFee = feeItem.data) - val spendAmount = dataState.amount?.let { amount -> - val fromToken = dataState.fromCurrency ?: return@let null - swapInteractor.getSwapAmountForToken(amount, fromToken) - } ?: dataState.approveDataModel?.fromTokenAmount - spendAmount ?: return@UiActions - val fromToken = dataState.fromCurrency ?: return@UiActions + onClickFee = { + val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL + val txFeeState = dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState + ?: return@UiActions + uiState = stateBuilder.showSelectFeeBottomSheet( + uiState = uiState, + selectedFee = selectedFee, + txFeeState = txFeeState, + ) { + uiState = stateBuilder.dismissBottomSheet(uiState) + } + }, + onSelectFeeType = { + val state = dataState.getCurrentLoadedSwapState() ?: return@UiActions + val fromToken = dataState.fromCryptoCurrency ?: return@UiActions + val amountToSwap = dataState.amount ?: return@UiActions + val selectedProvider = dataState.selectedProvider ?: return@UiActions + uiState = stateBuilder.updateSelectedFeeBottomSheet(uiState, it.feeType) + dataState = dataState.copy(selectedFee = it) viewModelScope.launch(dispatchers.io) { - val isFeeEnough = swapInteractor.checkFeeIsEnough( - fee = feeItem.data.feeValue, - spendAmount = spendAmount, + val updatedState = swapInteractor.updateQuotesStateWithSelectedFee( + state = state, + selectedFee = it.feeType, + fromToken = fromToken, + amountToSwap = amountToSwap, networkId = dataState.networkId, + ) + setupLoadedState(selectedProvider, updatedState, fromToken) + } + }, + onProviderClick = { providerId -> + analyticsEventHandler.send(SwapEvents.ProviderClicked) + val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() + val pricesLowerBest = getPricesLowerBest(states) + val unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates) + uiState = stateBuilder.showSelectProviderBottomSheet( + uiState = uiState, + selectedProviderId = providerId, + pricesLowerBest = pricesLowerBest, + unavailableProviders = unavailableProviders, + providersStates = dataState.lastLoadedSwapStates, + ) { uiState = stateBuilder.dismissBottomSheet(uiState) } + }, + onProviderSelect = { + val provider = findAndSelectProvider(it) + val swapState = dataState.lastLoadedSwapStates[provider] + val fromToken = dataState.fromCryptoCurrency + if (provider != null && swapState != null && fromToken != null) { + analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) + uiState = stateBuilder.updateSelectedProvider(uiState, provider.providerId) + setupLoadedState( + provider = provider, + state = swapState, fromToken = fromToken, ) - uiState = stateBuilder.updateFeeSelectedItem(uiState, feeItem, isFeeEnough) } }, + onBuyClick = { + swapInteractor.getSelectedWallet()?.let { + swapRouter.openTokenDetails(it.walletId, swapInteractor.getNativeToken(dataState.networkId)) + } + }, + onRetryClick = { + startLoadingQuotesFromLastState() + }, ) } + private fun findAndSelectProvider(providerId: String): SwapProvider? { + val selectedProvider = dataState.lastLoadedSwapStates.keys.firstOrNull { it.providerId == providerId } + if (selectedProvider != null) { + dataState = dataState.copy( + selectedProvider = selectedProvider, + ) + } + return selectedProvider + } + + private fun findBestQuoteProvider(state: SuccessLoadedSwapData): SwapProvider? { + // finding best quotes + return state.minByOrNull { + if (!it.value.fromTokenInfo.amountFiat.isNullOrZero() && + !it.value.toTokenInfo.amountFiat.isNullOrZero() + ) { + it.value.fromTokenInfo.amountFiat.divide( + it.value.toTokenInfo.amountFiat, + it.value.toTokenInfo.cryptoCurrencyStatus.currency.decimals, + RoundingMode.HALF_UP, + ) + } else { + BigDecimal.ZERO + } + }?.key + } + + private fun getPricesLowerBest(state: SuccessLoadedSwapData): Map { + val bestRateEntry = state.maxByOrNull { it.value.toTokenInfo.tokenAmount.value } ?: return emptyMap() + val bestRate = bestRateEntry.value.toTokenInfo.tokenAmount.value + val hundredPercent = BigDecimal("100") + return state.mapNotNull { + if (it.key != bestRateEntry.key) { + val amount = it.value.toTokenInfo.tokenAmount.value + val percentDiff = BigDecimal.ONE.minus( + amount.divide(bestRate, RoundingMode.HALF_UP), + ).multiply(hundredPercent) + percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat().absoluteValue + } else { + null + } + } + } + + private fun createSelectedAppCurrencyFlow(): StateFlow { + return getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + } + + private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List { + val groupToFind = if (isOrderReversed) { + dataState.tokensDataState?.fromGroup + } else { + dataState.tokensDataState?.toGroup + } ?: return emptyList() + + val idToFind = if (isOrderReversed) { + fromToken.currency.id.value + } else { + toToken.currency.id.value + } + + return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers ?: emptyList() + } + + private fun getAllProviders(): List { + dataState.tokensDataState?.let { data -> + val allProviders = + data.fromGroup.available.flatMap { it.providers } + data.toGroup.available.flatMap { it.providers } + return allProviders.distinct() + } ?: return emptyList() + } + + private fun getUnavailableProvidersFor(state: Map): List { + return getAllProviders().filterNot { it in state } + } + + private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { + return this + .filter { it.value is SwapState.QuotesLoadedState } + .mapValues { it.value as SwapState.QuotesLoadedState } + } + companion object { + private const val loggingTag = "SwapViewModel" private const val INITIAL_AMOUNT = "" private const val UPDATE_DELAY = 10000L private const val DEBOUNCE_AMOUNT_DELAY = 1000L diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index bc5b2cda79..8c4c106fa0 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) implementation(deps.compose.ui.utils) + implementation(deps.compose.constraintLayout) implementation(deps.arrow.core) implementation(deps.jodatime) @@ -36,6 +37,7 @@ dependencies { implementation(deps.tangem.blockchain) implementation(deps.tangem.card.core) implementation(deps.timber) + implementation(deps.lifecycle.compose) /** DI */ implementation(deps.hilt.android) @@ -48,6 +50,7 @@ dependencies { implementation(projects.core.utils) implementation(projects.core.analytics) implementation(projects.core.analytics.models) + implementation(projects.core.datasource) /** Domain modules */ implementation(projects.domain.appCurrency) @@ -65,7 +68,11 @@ dependencies { implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) + /** Temp dependency to swap domain */ + implementation(projects.features.swap.domain) + /** Feature Apis */ implementation(projects.features.tokendetails.api) implementation(projects.features.send.api) + implementation(projects.features.swap.domain) } \ 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 2a25900715..458e547586 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 @@ -101,6 +101,7 @@ internal object TokenDetailsPreviewData { ), dialogConfig = null, pendingTxs = persistentListOf(), + swapTxs = persistentListOf(), pullToRefreshConfig = pullToRefreshConfig, bottomSheetConfig = null, isBalanceHidden = false, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/SwapTransactionsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/SwapTransactionsState.kt new file mode 100644 index 0000000000..5e42550ab6 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/SwapTransactionsState.kt @@ -0,0 +1,40 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state + +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.swap.domain.models.domain.SwapProvider +import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications +import kotlinx.coroutines.flow.MutableStateFlow + +internal data class SwapTransactionsState( + val txId: String, + val provider: SwapProvider, + val txUrl: String? = null, + val timestamp: TextReference, + val fiatSymbol: String, + val activeStatus: MutableStateFlow, + val hasFailed: MutableStateFlow, + val statuses: MutableStateFlow>, + val notification: MutableStateFlow = MutableStateFlow(null), + val toCryptoCurrencyId: CryptoCurrency.ID, + val toCryptoAmount: String, + val toCryptoSymbol: String, + val toFiatAmount: String, + val toCurrencyIcon: TokenIconState, + val fromCryptoCurrencyId: CryptoCurrency.ID, + val fromCryptoAmount: String, + val fromCryptoSymbol: String, + val fromFiatAmount: String, + val fromCurrencyIcon: TokenIconState, + val onClick: () -> Unit, + val onGoToProviderClick: (String) -> Unit, +) + +internal class ExchangeStatusState( + val status: ExchangeStatus, + val text: TextReference, + val isActive: Boolean, + val isDone: Boolean, +) \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt index 15f290adde..f9f5dfcabd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsState.kt @@ -19,6 +19,7 @@ internal data class TokenDetailsState( val marketPriceBlockState: MarketPriceBlockState, val notifications: ImmutableList, val pendingTxs: PersistentList, + val swapTxs: PersistentList, val txHistoryState: TxHistoryState, val dialogConfig: TokenDetailsDialogConfig?, val pullToRefreshConfig: TokenDetailsPullToRefreshConfig, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotifications.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotifications.kt new file mode 100644 index 0000000000..1da8fd0672 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotifications.kt @@ -0,0 +1,38 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.components + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.tokendetails.impl.R + +@Immutable +internal sealed class ExchangeStatusNotifications(val config: NotificationConfig) { + + data class NeedVerification( + val onGoToProviderClick: () -> Unit, + ) : ExchangeStatusNotifications( + config = NotificationConfig( + title = TextReference.Res(R.string.express_exchange_notification_verification_title), + subtitle = TextReference.Res(R.string.express_exchange_notification_verification_text), + iconResId = R.drawable.ic_alert_triangle_20, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = TextReference.Res(R.string.express_go_to_provider), + onClick = onGoToProviderClick, + ), + ), + ) + + data class Failed( + val onGoToProviderClick: () -> Unit, + ) : ExchangeStatusNotifications( + config = NotificationConfig( + title = TextReference.Res(R.string.express_exchange_notification_failed_title), + subtitle = TextReference.Res(R.string.express_exchange_notification_failed_text), + iconResId = R.drawable.ic_alert_circle_24, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = TextReference.Res(R.string.express_go_to_provider), + onClick = onGoToProviderClick, + ), + ), + ) +} \ No newline at end of file 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 e92142e90c..30d410450e 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 @@ -45,6 +45,7 @@ internal class TokenDetailsSkeletonStateConverter( marketPriceBlockState = MarketPriceBlockState.Loading(value.symbol), notifications = persistentListOf(), pendingTxs = persistentListOf(), + swapTxs = persistentListOf(), txHistoryState = TxHistoryState.Content( contentItems = MutableStateFlow( value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick), 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 997a6e985a..2c2330ba57 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 @@ -22,11 +22,13 @@ import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.txhistory.models.TxHistoryItem import com.tangem.domain.txhistory.models.TxHistoryListError import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsDialogConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadedTxHistoryConverter import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.txhistory.TokenDetailsLoadingTxHistoryConverter.TokenDetailsLoadingTxHistoryModel +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow @@ -233,6 +235,18 @@ internal class TokenDetailsStateFactory( return state.copy(notifications = notificationConverter.removeRentInfo(state)) } + fun getStateWithExchangeStatusBottomSheet(swapTxState: SwapTransactionsState): TokenDetailsState { + return currentStateProvider().copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = clickIntents::onDismissBottomSheet, + content = ExchangeStatusBottomSheetConfig( + value = swapTxState, + ), + ), + ) + } + fun getStateAndTriggerEvent( state: TokenDetailsState, errorMessage: TextReference, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt new file mode 100644 index 0000000000..3650c86759 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -0,0 +1,259 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import com.tangem.common.Provider +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.toDateFormat +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel +import com.tangem.feature.tokendetails.presentation.tokendetails.state.ExchangeStatusState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotifications +import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents +import com.tangem.features.tokendetails.impl.R +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import java.math.BigDecimal + +internal class TokenDetailsSwapTransactionsStateConverter( + private val clickIntents: TokenDetailsClickIntents, + private val cryptoCurrency: CryptoCurrency, + private val analyticsEventsHandlerProvider: Provider, + appCurrencyProvider: Provider, +) : Converter> { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val appCurrency = appCurrencyProvider() + + override fun convert(value: Unit): PersistentList { + return persistentListOf() + } + + fun convert( + savedTransactions: List, + cryptoStatusList: List, + ): PersistentList { + val result = mutableListOf() + + savedTransactions.forEach { swapCurrency -> + val firstStatus = cryptoStatusList.first { it.currency.id.value == swapCurrency.fromCryptoCurrencyId } + val secondStatus = cryptoStatusList.first { it.currency.id.value == swapCurrency.toCryptoCurrencyId } + + val (fromCurrency, toCurrency) = if (swapCurrency.fromCryptoCurrencyId == firstStatus.currency.id.value) { + firstStatus to secondStatus + } else { + secondStatus to firstStatus + } + + swapCurrency.transactions.forEach { transaction -> + val toAmount = transaction.toCryptoAmount + val fromAmount = transaction.fromCryptoAmount + val toFiatAmount = toAmount.multiply(toCurrency.value.fiatRate) + val fromFiatAmount = fromAmount.multiply(fromCurrency.value.fiatRate) + val timestamp = transaction.timestamp + result.add( + SwapTransactionsState( + txId = transaction.txId, + provider = transaction.provider, + txUrl = transaction.status?.txUrl, + timestamp = TextReference.Str("${timestamp.toDateFormat()}, ${timestamp.toTimeFormat()}"), + fiatSymbol = appCurrency.symbol, + statuses = MutableStateFlow(getStatuses(transaction.status?.status)), + hasFailed = MutableStateFlow(transaction.status?.status == ExchangeStatus.Failed), + activeStatus = MutableStateFlow(transaction.status?.status), + notification = MutableStateFlow( + getNotification( + transaction.status?.status, + transaction.status?.txUrl, + ), + ), + toCryptoCurrencyId = toCurrency.currency.id, + toCryptoAmount = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = toAmount, + cryptoCurrency = toCurrency.currency, + ), + toCryptoSymbol = toCurrency.currency.symbol, + toFiatAmount = getFiatAmount(toFiatAmount), + toCurrencyIcon = iconStateConverter.convert(toCurrency), + fromCryptoCurrencyId = fromCurrency.currency.id, + fromCryptoAmount = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = fromAmount, + cryptoCurrency = fromCurrency.currency, + ), + fromCryptoSymbol = fromCurrency.currency.symbol, + fromFiatAmount = getFiatAmount(fromFiatAmount), + fromCurrencyIcon = iconStateConverter.convert(fromCurrency), + onClick = { clickIntents.onSwapTransactionClick(transaction.txId) }, + onGoToProviderClick = { url -> + analyticsEventsHandlerProvider().send( + TokenExchangeAnalyticsEvent.GoToProviderStatus(cryptoCurrency.symbol), + ) + clickIntents.onGoToProviderClick(url = url) + }, + ), + ) + } + } + return result.toPersistentList() + } + + fun updateTxStatus(tx: SwapTransactionsState, statusModel: ExchangeStatusModel?) { + if (statusModel == null || tx.activeStatus.value == statusModel.status) return + val hasFailed = tx.hasFailed.value || statusModel.status == ExchangeStatus.Failed + tx.activeStatus.update { statusModel.status } + tx.hasFailed.update { hasFailed } + tx.notification.update { getNotification(statusModel.status, statusModel.txUrl) } + tx.statuses.update { getStatuses(statusModel.status, hasFailed) } + } + + private fun getFiatAmount(toFiatAmount: BigDecimal): String { + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = toFiatAmount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + + private fun getNotification(status: ExchangeStatus?, txUrl: String?): ExchangeStatusNotifications? { + if (txUrl == null) return null + return when (status) { + ExchangeStatus.Failed -> { + ExchangeStatusNotifications.Failed { + analyticsEventsHandlerProvider().send( + TokenExchangeAnalyticsEvent.GoToProviderFail(cryptoCurrency.symbol), + ) + clickIntents.onGoToProviderClick(txUrl) + } + } + ExchangeStatus.Verifying -> { + ExchangeStatusNotifications.NeedVerification { + analyticsEventsHandlerProvider().send( + TokenExchangeAnalyticsEvent.GoToProviderKYC(cryptoCurrency.symbol), + ) + clickIntents.onGoToProviderClick(txUrl) + } + } + else -> null + } + } + + private fun getStatuses(status: ExchangeStatus?, hasFailed: Boolean = false): List { + if (status == null) return emptyList() + val isWaiting = status == ExchangeStatus.New || status == ExchangeStatus.Waiting + val isConfirming = status == ExchangeStatus.Confirming + val isVerifying = status == ExchangeStatus.Verifying + val isExchanging = status == ExchangeStatus.Exchanging + val isFailed = status == ExchangeStatus.Failed + val isSending = status == ExchangeStatus.Sending + val isRefunded = status == ExchangeStatus.Refunded + + val isWaitingDone = !isWaiting + val isConfirmingDone = !isConfirming && isWaitingDone + val isExchangingDone = !isExchanging && isConfirmingDone + val isSendingDone = !isSending && !isVerifying && !isFailed && isExchangingDone + + return listOf( + waitStep(isWaiting, isWaitingDone), + confirmStep(isConfirming, isConfirmingDone), + exchangeStep( + isExchanging = isExchanging, + isExchangingDone = isExchangingDone, + isRefunded = isRefunded, + hasFailed = hasFailed, + isVerifying = isVerifying, + isFailed = isFailed, + ), + sendStep( + isSending = isSending, + isSendingDone = isSendingDone, + isRefunded = isRefunded, + hasFailed = hasFailed, + ), + ) + } + + private fun waitStep(isNew: Boolean, isNewDone: Boolean) = ExchangeStatusState( + status = ExchangeStatus.New, + text = when { + isNew -> TextReference.Res(R.string.express_exchange_status_receiving_active) + isNewDone -> TextReference.Res(R.string.express_exchange_status_received) + else -> TextReference.Res(R.string.express_exchange_status_receiving) + }, + isActive = isNew, + isDone = isNewDone, + ) + + private fun confirmStep(isConfirming: Boolean, isConfirmingDone: Boolean) = ExchangeStatusState( + status = ExchangeStatus.Confirming, + text = when { + isConfirming -> TextReference.Res(R.string.express_exchange_status_confirming_active) + isConfirmingDone -> TextReference.Res(R.string.express_exchange_status_confirmed) + else -> TextReference.Res(R.string.express_exchange_status_confirming) + }, + isActive = isConfirming, + isDone = isConfirmingDone, + ) + + private fun exchangeStep( + isExchanging: Boolean, + isExchangingDone: Boolean, + isRefunded: Boolean, + hasFailed: Boolean, + isVerifying: Boolean = false, + isFailed: Boolean = false, + ) = when { + isVerifying -> ExchangeStatusState( + status = ExchangeStatus.Verifying, + text = TextReference.Res(R.string.express_exchange_status_verifying), + isActive = true, + isDone = false, + ) + hasFailed || isFailed || isRefunded -> ExchangeStatusState( + status = ExchangeStatus.Failed, + text = TextReference.Res(R.string.express_exchange_status_failed), + isActive = isFailed || isRefunded, + isDone = isRefunded, + ) + else -> ExchangeStatusState( + status = ExchangeStatus.Exchanging, + text = when { + isExchanging -> TextReference.Res(R.string.express_exchange_status_exchanging_active) + isExchangingDone -> TextReference.Res(R.string.express_exchange_status_exchanged) + else -> TextReference.Res(R.string.express_exchange_status_exchanging) + }, + isActive = isExchanging, + isDone = isExchangingDone, + ) + } + + private fun sendStep(isSending: Boolean, isSendingDone: Boolean, isRefunded: Boolean, hasFailed: Boolean) = when { + hasFailed || isRefunded -> ExchangeStatusState( + status = ExchangeStatus.Refunded, + text = TextReference.Res(R.string.express_exchange_status_refunded), + isActive = false, + isDone = isRefunded, + ) + else -> ExchangeStatusState( + status = ExchangeStatus.Sending, + text = when { + isSending -> TextReference.Res(R.string.express_exchange_status_sending_active) + isSendingDone -> TextReference.Res(R.string.express_exchange_status_sent) + else -> TextReference.Res(R.string.express_exchange_status_sending) + }, + isActive = isSending, + isDone = isSendingDone, + ) + } +} \ No newline at end of file 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 9b4e73ef9a..57f5e40688 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 @@ -2,7 +2,9 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.ExperimentalMaterialApi @@ -40,6 +42,9 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsDialogs import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsTopAppBar import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheet +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.ExchangeStatusBottomSheetConfig +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange.swapTransactionsItems // TODO: Split to blocks [REDACTED_JIRA] @Suppress("LongMethod") @@ -117,6 +122,11 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) { ) } + swapTransactionsItems( + state.swapTxs, + itemModifier, + ) + txHistoryItems( state = state.txHistoryState, isBalanceHidden = state.isBalanceHidden, @@ -141,6 +151,9 @@ internal fun TokenDetailsScreen(state: TokenDetailsState) { is ChooseAddressBottomSheetConfig -> { ChooseAddressBottomSheet(config = config) } + is ExchangeStatusBottomSheetConfig -> { + ExchangeStatusBottomSheet(config = config) + } } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index 8280f9aeab..f9911ae521 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -39,7 +39,7 @@ internal fun TokenDetailsBalanceBlock( start = TangemTheme.dimens.spacing12, end = TangemTheme.dimens.spacing12, ), - text = stringResource(id = R.string.onboarding_balance_title), + text = stringResource(id = R.string.common_balance_title), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, maxLines = 1, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeEstimate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeEstimate.kt new file mode 100644 index 0000000000..6aee05217a --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeEstimate.kt @@ -0,0 +1,71 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +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 +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.components.inputrow.InputRowApprox +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tokendetails.impl.R + +@Suppress("LongParameterList") +@Composable +internal fun ExchangeEstimate( + timestamp: TextReference, + fromTokenIconState: TokenIconState, + toTokenIconState: TokenIconState, + fromCryptoAmount: TextReference, + fromCryptoSymbol: String, + toCryptoAmount: TextReference, + toCryptoSymbol: String, + fromFiatAmount: TextReference, + toFiatAmount: TextReference, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing14, + bottom = TangemTheme.dimens.spacing2, + ), + ) { + Text( + text = stringResource(id = R.string.express_estimated_amount), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + Text( + text = timestamp.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + InputRowApprox( + leftIcon = fromTokenIconState, + leftTitle = fromCryptoAmount, + leftSubtitle = fromFiatAmount, + leftTitleEllipsisOffset = fromCryptoSymbol.length, + rightIcon = toTokenIconState, + rightTitle = toCryptoAmount, + rightSubtitle = toFiatAmount, + rightTitleEllipsisOffset = toCryptoSymbol.length, + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeProvider.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeProvider.kt new file mode 100644 index 0000000000..26c7393a85 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeProvider.kt @@ -0,0 +1,41 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +import androidx.compose.foundation.background +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 androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import com.tangem.core.ui.R +import com.tangem.core.ui.components.inputrow.InputRowBestRate +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun ExchangeProvider(providerName: TextReference, providerType: TextReference, imageUrl: String) { + Column( + modifier = Modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action), + ) { + Text( + text = stringResource(id = R.string.express_provider), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + ), + ) + InputRowBestRate( + imageUrl = imageUrl, + title = providerName, + titleExtra = providerType, + subtitle = TextReference.Res(R.string.express_floating_rate), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBlock.kt new file mode 100644 index 0000000000..9d44f11a9e --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBlock.kt @@ -0,0 +1,211 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.tokendetails.presentation.tokendetails.state.ExchangeStatusState +import com.tangem.features.tokendetails.impl.R +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +internal fun ExchangeStatusBlock( + statuses: MutableStateFlow>, + showLink: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val statusValues = statuses.collectAsStateWithLifecycle() + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.action) + .padding( + vertical = TangemTheme.dimens.spacing14, + horizontal = TangemTheme.dimens.spacing12, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(bottom = TangemTheme.dimens.spacing16), + ) { + Text( + text = stringResource(id = R.string.express_exchange_status_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerWMax() + AnimatedVisibility(visible = showLink) { + Row( + modifier = Modifier.clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_top_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.spacing16) + .padding(end = TangemTheme.dimens.spacing2), + ) + Text( + text = stringResource(id = R.string.express_go_to_provider), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + + statusValues.value.forEachIndexed { index, item -> + ExchangeStatusStep( + stepStatus = item, + isLast = index == statusValues.value.lastIndex, + ) + } + } +} + +@Composable +private fun ExchangeStatusStep( + stepStatus: ExchangeStatusState, + modifier: Modifier = Modifier, + isLast: Boolean = false, +) { + Row(modifier = modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + targetState = stepStatus, + label = "Exchange Step Change Success", + modifier = Modifier + .size(TangemTheme.dimens.size20), + ) { + when { + it.status == ExchangeStatus.Failed -> ExchangeStep( + iconRes = R.drawable.ic_close_24, + color = TangemTheme.colors.icon.warning, + isDone = it.isDone, + ) + it.status == ExchangeStatus.Verifying -> ExchangeStep( + iconRes = R.drawable.ic_exclamation_24, + color = TangemTheme.colors.icon.attention, + isDone = it.isDone, + ) + it.isDone -> ExchangeStep( + iconRes = R.drawable.ic_check_24, + color = TangemTheme.colors.icon.primary1, + isDone = true, + ) + it.isActive -> ExchangeStepInProgress() + else -> ExchangeStepDefault() + } + } + if (!isLast) { + ExchangeStepSeparator() + } + } + ExchangeStatusStepText(stepStatus) + } +} + +@Composable +private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) { + val textColor = when { + stepStatus.status == ExchangeStatus.Failed && !stepStatus.isDone -> TangemTheme.colors.icon.warning + stepStatus.status == ExchangeStatus.Verifying && !stepStatus.isDone -> TangemTheme.colors.icon.attention + stepStatus.isDone -> TangemTheme.colors.text.primary1 + !stepStatus.isActive -> TangemTheme.colors.text.disabled + else -> TangemTheme.colors.text.primary1 + } + + Text( + text = stepStatus.text.resolveReference(), + style = TangemTheme.typography.body2, + color = textColor, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12), + ) +} + +@Composable +private fun ExchangeStepDefault() { + Box( + modifier = Modifier + .border( + width = TangemTheme.dimens.size1_5, + color = TangemTheme.colors.field.focused, + shape = CircleShape, + ) + .padding(TangemTheme.dimens.spacing2), + ) +} + +@Composable +private fun ExchangeStep(color: Color, @DrawableRes iconRes: Int, isDone: Boolean) { + val (iconColor, borderColor) = if (isDone) { + TangemTheme.colors.icon.primary1 to TangemTheme.colors.field.focused + } else { + color to color + } + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = iconColor, + modifier = Modifier + .border( + width = TangemTheme.dimens.size1_5, + color = borderColor, + shape = CircleShape, + ) + .padding(TangemTheme.dimens.spacing2), + ) +} + +@Composable +private fun ExchangeStepInProgress() { + CircularProgressIndicator( + color = TangemTheme.colors.icon.primary1, + strokeWidth = TangemTheme.dimens.size2, + modifier = Modifier + .padding(TangemTheme.dimens.spacing2) + .size(TangemTheme.dimens.size14), + ) +} + +@Composable +private fun ExchangeStepSeparator() { + Box( + modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing2) + .size( + width = TangemTheme.dimens.size1_5, + height = TangemTheme.dimens.size10, + ) + .background( + color = TangemTheme.colors.field.focused, + shape = CircleShape, + ), + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBottomSheet.kt new file mode 100644 index 0000000000..f8850738e2 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusBottomSheet.kt @@ -0,0 +1,107 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +import androidx.compose.animation.AnimatedContent +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.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH10 +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState + +@Composable +internal fun ExchangeStatusBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + contentColor = TangemTheme.colors.background.tertiary, + ) { content: ExchangeStatusBottomSheetConfig -> + ExchangeStatusBottomSheetContent(content = content) + } +} + +@Composable +private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetConfig) { + val config = content.value + val status = config.activeStatus.collectAsStateWithLifecycle() + val notification = config.notification.collectAsStateWithLifecycle() + Column( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) { + SpacerH10() + Text( + text = stringResource(id = R.string.express_exchange_status_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.align(CenterHorizontally), + ) + SpacerH10() + Text( + text = stringResource(id = R.string.express_exchange_status_subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier + .align(CenterHorizontally), + ) + SpacerH16() + ExchangeEstimate( + timestamp = config.timestamp, + fromTokenIconState = config.fromCurrencyIcon, + toTokenIconState = config.toCurrencyIcon, + fromCryptoAmount = TextReference.Str(config.fromCryptoAmount), + fromCryptoSymbol = config.fromCryptoSymbol, + toCryptoAmount = TextReference.Str(config.toCryptoAmount), + toCryptoSymbol = config.toCryptoSymbol, + fromFiatAmount = TextReference.Str(config.fromFiatAmount), + toFiatAmount = TextReference.Str(config.toFiatAmount), + ) + SpacerH12() + ExchangeProvider( + providerName = TextReference.Str(config.provider.name), + providerType = TextReference.Str(config.provider.type.name), + imageUrl = config.provider.imageLarge, + ) + SpacerH12() + ExchangeStatusBlock( + statuses = config.statuses, + showLink = notification.value == null && config.txUrl != null, + onClick = { config.onGoToProviderClick(config.txUrl.orEmpty()) }, + ) + AnimatedContent( + targetState = notification.value, + label = "Exchange Status Notification Change", + ) { + it?.let { + val tint = when (status.value) { + ExchangeStatus.Verifying -> TangemTheme.colors.icon.attention + ExchangeStatus.Failed -> TangemTheme.colors.icon.warning + else -> null + } + Notification( + config = it.config, + iconTint = tint, + containerColor = TangemTheme.colors.background.action, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing12), + ) + } + } + SpacerH24() + } +} + +internal data class ExchangeStatusBottomSheetConfig( + val value: SwapTransactionsState, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusItems.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusItems.kt new file mode 100644 index 0000000000..c73e3e3051 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/exchange/ExchangeStatusItems.kt @@ -0,0 +1,242 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.exchange + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Icon +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.constraintlayout.compose.* +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.atoms.text.EllipsisText +import com.tangem.core.ui.components.atoms.text.TextEllipsis +import com.tangem.core.ui.components.currency.tokenicon.TokenIcon +import com.tangem.core.ui.components.currency.tokenicon.TokenIconState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState +import com.tangem.features.tokendetails.impl.R +import kotlinx.collections.immutable.PersistentList + +@OptIn(ExperimentalFoundationApi::class) +internal fun LazyListScope.swapTransactionsItems( + swapTxs: PersistentList, + modifier: Modifier = Modifier, +) { + if (swapTxs.isNotEmpty()) { + items( + count = swapTxs.size, + key = { swapTxs[it].txId }, + contentType = { swapTxs[it]::class.java }, + ) { + val item = swapTxs[it] + val status = item.activeStatus.collectAsStateWithLifecycle() + val (iconRes, tint) = when (status.value) { + ExchangeStatus.Verifying -> R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention + ExchangeStatus.Failed -> R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning + else -> null to null + } + + ExchangeStatusItem( + providerName = item.provider.name, + fromTokenIconState = item.fromCurrencyIcon, + toTokenIconState = item.toCurrencyIcon, + fromAmount = item.fromCryptoAmount, + fromSymbol = item.fromCryptoSymbol, + toSymbol = item.toCryptoSymbol, + onClick = item.onClick, + infoIconRes = iconRes, + infoIconTint = tint, + modifier = modifier.animateItemPlacement(), + ) + } + } +} + +@Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod", "LongParameterList") +@Composable +private fun ExchangeStatusItem( + providerName: String, + fromTokenIconState: TokenIconState, + toTokenIconState: TokenIconState, + fromAmount: String, + fromSymbol: String, + toSymbol: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + @DrawableRes infoIconRes: Int? = null, + infoIconTint: Color? = null, +) { + ConstraintLayout( + modifier = modifier + .fillMaxWidth() + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(TangemTheme.colors.background.primary) + .clickable { onClick() } + .padding(TangemTheme.dimens.spacing12), + ) { + val (titleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = createRefs() + val padding6 = TangemTheme.dimens.spacing6 + + Text( + text = stringResource(id = R.string.express_exchange_by, providerName), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.constrainAs(titleRef) { + start.linkTo(parent.start) + top.linkTo(parent.top) + }, + ) + TokenIcon( + state = fromTokenIconState, + shouldDisplayNetwork = false, + modifier = Modifier + .size(TangemTheme.dimens.size20) + .constrainAs(fromIconRef) { + start.linkTo(parent.start) + top.linkTo(titleRef.bottom, padding6) + bottom.linkTo(parent.bottom) + }, + ) + EllipsisText( + text = fromAmount, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ellipsis = TextEllipsis.OffsetEnd(fromSymbol.length), + modifier = Modifier.constrainAs(fromRef) { + start.linkTo(fromIconRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(swapIconRef.start) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints.atMostWrapContent + }, + ) + Icon( + painter = painterResource(id = R.drawable.ic_forward_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.size12) + .constrainAs(swapIconRef) { + start.linkTo(fromRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(toIconRef.start) + bottom.linkTo(parent.bottom) + }, + ) + TokenIcon( + state = toTokenIconState, + shouldDisplayNetwork = false, + modifier = Modifier + .size(TangemTheme.dimens.size20) + .constrainAs(toIconRef) { + start.linkTo(swapIconRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(toRef.start) + bottom.linkTo(parent.bottom) + }, + ) + Text( + text = toSymbol, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.constrainAs(toRef) { + start.linkTo(toIconRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(infoIconRef.start, padding6, padding6) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints.atLeastWrapContent + }, + ) + Icon( + painter = painterResource(id = infoIconRes ?: R.drawable.ic_alert_triangle_20), + contentDescription = null, + tint = infoIconTint ?: TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.size20) + .constrainAs(infoIconRef) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(iconRef.start) + visibility = if (infoIconRes == null) { + Visibility.Gone + } else { + Visibility.Visible + } + }, + ) + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right_24), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.size24) + .constrainAs(iconRef) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + ) + } +} + +//region Preview +@Preview +@Composable +private fun ExchangeStatusItemPreview_Light( + @PreviewParameter(ExchangeStatusItemsPreviewParameterProvider::class) amount: String, +) { + TangemTheme { + ExchangeStatusItem( + providerName = "ChangeNow", + fromTokenIconState = TokenIconState.Loading, + toTokenIconState = TokenIconState.Loading, + fromAmount = amount, + fromSymbol = "USDT", + toSymbol = "USDT", + onClick = {}, + infoIconRes = null, + infoIconTint = null, + ) + } +} + +@Preview +@Composable +private fun ExchangeStatusItemPreview_Dark( + @PreviewParameter(ExchangeStatusItemsPreviewParameterProvider::class) amount: String, +) { + TangemTheme(isDark = true) { + ExchangeStatusItem( + providerName = "ChangeNow", + fromTokenIconState = TokenIconState.Loading, + toTokenIconState = TokenIconState.Loading, + fromAmount = amount, + fromSymbol = "USDT", + toSymbol = "USDT", + onClick = {}, + infoIconRes = null, + infoIconTint = null, + ) + } +} + +private class ExchangeStatusItemsPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + "1111111111111111111111111111 USDT", + "11111 USDT", + ) +} +//endregion \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt new file mode 100644 index 0000000000..13445dc76d --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/ExchangeStatusFactory.kt @@ -0,0 +1,162 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels + +import arrow.core.getOrElse +import com.tangem.common.Provider +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.datasource.local.swaptx.ExchangeAnalyticsStatus +import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.swap.domain.SwapRepository +import com.tangem.feature.swap.domain.SwapTransactionRepository +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionListModel +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +@Suppress("LongParameterList") +internal class ExchangeStatusFactory( + private val swapTransactionRepository: SwapTransactionRepository, + private val swapRepository: SwapRepository, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + private val swapTransactionStatusStore: SwapTransactionStatusStore, + private val dispatchers: CoroutineDispatcherProvider, + private val clickIntents: TokenDetailsClickIntents, + private val appCurrencyProvider: Provider, + private val analyticsEventsHandlerProvider: Provider, + private val userWalletId: UserWalletId, + private val cryptoCurrency: CryptoCurrency, +) { + + private val swapTransactionsStateConverter by lazy { + TokenDetailsSwapTransactionsStateConverter( + clickIntents = clickIntents, + cryptoCurrency = cryptoCurrency, + appCurrencyProvider = appCurrencyProvider, + analyticsEventsHandlerProvider = analyticsEventsHandlerProvider, + ) + } + + operator fun invoke() = combine( + flow = swapTransactionRepository.getTransactions(userWalletId, cryptoCurrency.id), + flow2 = getWalletCryptoCurrencies().conflate(), + ) { savedTransactions, cryptoCurrenciesStatusList -> + getExchangeStatusState( + savedTransactions = savedTransactions, + cryptoCurrencyStatusList = cryptoCurrenciesStatusList, + ) + } + + private fun getWalletCryptoCurrencies() = flow { + val selectedWallet = getSelectedWalletSyncUseCase().fold( + ifLeft = { null }, + ifRight = { it }, + ) + requireNotNull(selectedWallet) { "No selected wallet" } + + val cryptoCurrenciesList = getMultiCryptoCurrencyStatusUseCase(selectedWallet.walletId) + .getOrElse { emptyList() } + + emit(cryptoCurrenciesList) + } + + suspend fun updateSwapTxStatuses(swapTxList: PersistentList) = withContext(dispatchers.io) { + swapTxList.map { tx -> + async { + val statusModel = getExchangeStatus(tx.txId) + swapTransactionsStateConverter.updateTxStatus(tx, statusModel) + tx.removeIfFinished(statusModel?.status) + } + } + .awaitAll() + .filterNotNull() + .toPersistentList() + } + + private suspend fun getExchangeStatus(txId: String): ExchangeStatusModel? { + return swapRepository.getExchangeStatus(txId) + .fold( + ifLeft = { null }, + ifRight = { + sendStatusUpdateAnalytics(it) + it + }, + ) + } + + private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel) { + val txId = statusModel.txId ?: return + val status = toAnalyticStatus(statusModel.status) ?: return + val savedStatus = swapTransactionStatusStore.getTransactionStatus(txId) + + if (savedStatus != status) { + analyticsEventsHandlerProvider().send( + TokenExchangeAnalyticsEvent.CexTxStatusChanged(cryptoCurrency.symbol, status.value), + ) + swapTransactionStatusStore.setTransactionStatus(txId, status) + } + } + + private fun getExchangeStatusState( + savedTransactions: List?, + cryptoCurrencyStatusList: List, + ): PersistentList { + if (savedTransactions == null || cryptoCurrencyStatusList.isEmpty()) { + return persistentListOf() + } + + return swapTransactionsStateConverter.convert( + savedTransactions = savedTransactions, + cryptoStatusList = cryptoCurrencyStatusList, + ) + } + + private suspend fun SwapTransactionsState.removeIfFinished(status: ExchangeStatus?) = when (status) { + null -> null // not found + ExchangeStatus.Refunded, ExchangeStatus.Finished -> { + swapTransactionRepository.removeTransaction( + userWalletId = userWalletId, + fromCryptoCurrencyId = fromCryptoCurrencyId, + toCryptoCurrencyId = toCryptoCurrencyId, + txId = txId, + ) + null + } + else -> { + this + } + } + + private fun toAnalyticStatus(status: ExchangeStatus?): ExchangeAnalyticsStatus? { + return when (status) { + ExchangeStatus.New, + ExchangeStatus.Waiting, + ExchangeStatus.Sending, + ExchangeStatus.Confirming, + ExchangeStatus.Exchanging, + -> ExchangeAnalyticsStatus.InProgress + ExchangeStatus.Verifying -> ExchangeAnalyticsStatus.KYC + ExchangeStatus.Failed -> ExchangeAnalyticsStatus.Fail + ExchangeStatus.Finished -> ExchangeAnalyticsStatus.Done + ExchangeStatus.Refunded -> ExchangeAnalyticsStatus.Refunded + else -> null + } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt index 992915899c..95093f7fdb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsClickIntents.kt @@ -38,4 +38,8 @@ interface TokenDetailsClickIntents { fun onDismissBottomSheet() fun onCloseRentInfoNotification() + + fun onSwapTransactionClick(txId: String) + + fun onGoToProviderClick(url: String) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt index eeb8b108b0..1a718c9846 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/viewmodels/TokenDetailsViewModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.extensions.resourceReference +import com.tangem.datasource.local.swaptx.SwapTransactionStatusStore import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -24,6 +25,7 @@ import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.NetworkAddress +import com.tangem.domain.tokens.models.analytics.TokenExchangeAnalyticsEvent import com.tangem.domain.tokens.models.analytics.TokenReceiveAnalyticsEvent import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase @@ -31,16 +33,19 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.feature.swap.domain.SwapRepository +import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter +import com.tangem.feature.tokendetails.presentation.tokendetails.state.SwapTransactionsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.features.tokendetails.impl.R import com.tangem.features.tokendetails.navigation.TokenDetailsRouter -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.coroutines.* import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.PersistentList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.* @@ -50,7 +55,7 @@ import java.math.BigDecimal import javax.inject.Inject import kotlin.properties.Delegates -@Suppress("LongParameterList", "LargeClass") +@Suppress("LongParameterList", "LargeClass", "TooManyFunctions") @HiltViewModel internal class TokenDetailsViewModel @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, @@ -67,6 +72,11 @@ internal class TokenDetailsViewModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getCurrencyWarningsUseCase: GetCurrencyWarningsUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getMultiCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusesSyncUseCase, + private val swapRepository: SwapRepository, + private val swapTransactionRepository: SwapTransactionRepository, + private val swapTransactionStatusStore: SwapTransactionStatusStore, private val isDemoCardUseCase: IsDemoCardUseCase, private val reduxStateHolder: ReduxStateHolder, private val analyticsEventsHandler: AnalyticsEventHandler, @@ -85,8 +95,11 @@ internal class TokenDetailsViewModel @Inject constructor( private val marketPriceJobHolder = JobHolder() private val refreshStateJobHolder = JobHolder() private val warningsJobHolder = JobHolder() + private val swapTxJobHolder = JobHolder() private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null + private var swapTxStatusTaskScheduler = SingleTaskScheduler>() + private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() private val stateFactory = TokenDetailsStateFactory( @@ -97,6 +110,22 @@ internal class TokenDetailsViewModel @Inject constructor( decimals = cryptoCurrency.decimals, ) + private val exchangeStatusFactory by lazy { + ExchangeStatusFactory( + swapTransactionRepository = swapTransactionRepository, + swapRepository = swapRepository, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + getMultiCryptoCurrencyStatusUseCase = getMultiCryptoCurrencyStatusUseCase, + swapTransactionStatusStore = swapTransactionStatusStore, + dispatchers = dispatchers, + clickIntents = this, + appCurrencyProvider = Provider { selectedAppCurrencyFlow.value }, + analyticsEventsHandlerProvider = Provider { analyticsEventsHandler }, + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + } + var uiState: TokenDetailsState by mutableStateOf(stateFactory.getInitialState(cryptoCurrency)) private set @@ -108,8 +137,14 @@ internal class TokenDetailsViewModel @Inject constructor( handleBalanceHiding(owner) } + override fun onCleared() { + swapTxStatusTaskScheduler.cancelTask() + super.onCleared() + } + private fun updateContent() { subscribeOnCurrencyStatusUpdates() + subscribeOnExchangeTransactionsUpdates() updateTxHistory(refresh = false, showItemsLoading = true) } @@ -176,6 +211,33 @@ internal class TokenDetailsViewModel @Inject constructor( } } + private fun subscribeOnExchangeTransactionsUpdates() { + viewModelScope.launch(dispatchers.io) { + swapTxStatusTaskScheduler.cancelTask() + exchangeStatusFactory.invoke() + .onEach { swapTxs -> + swapTxStatusTaskScheduler.scheduleTask( + viewModelScope, + PeriodicTask( + delay = EXCHANGE_STATUS_UPDATE_DELAY, + task = { + runCatching(dispatchers.io) { + exchangeStatusFactory.updateSwapTxStatuses(swapTxs) + } + }, + onSuccess = { updatedTxs -> + uiState = uiState.copy(swapTxs = updatedTxs) + }, + onError = {}, + ), + ) + } + .flowOn(dispatchers.io) + .launchIn(viewModelScope) + .saveIn(swapTxJobHolder) + } + } + /** * @param refresh - invalidate cache and get data from remote * @param showItemsLoading - show loading items placeholder. @@ -460,6 +522,7 @@ internal class TokenDetailsViewModel @Inject constructor( refresh = true, showItemsLoading = uiState.txHistoryState !is TxHistoryState.Content, ) + subscribeOnExchangeTransactionsUpdates() }, ).awaitAll() uiState = stateFactory.getRefreshedState() @@ -473,4 +536,18 @@ internal class TokenDetailsViewModel @Inject constructor( override fun onCloseRentInfoNotification() { uiState = stateFactory.getStateWithRemovedRentNotification() } + + override fun onSwapTransactionClick(txId: String) { + val swapTxState = uiState.swapTxs.first { it.txId == txId } + analyticsEventsHandler.send(TokenExchangeAnalyticsEvent.CexTxStatusOpened(cryptoCurrency.symbol)) + uiState = stateFactory.getStateWithExchangeStatusBottomSheet(swapTxState) + } + + override fun onGoToProviderClick(url: String) { + router.openUrl(url) + } + + private companion object { + const val EXCHANGE_STATUS_UPDATE_DELAY = 10_000L + } } \ No newline at end of file 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 5af5379a9e..9485b4d30c 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 @@ -9,4 +9,6 @@ interface WalletFeatureToggles { /** Availability of redesigned screen */ val isRedesignedScreenEnabled: Boolean + + val isWalletsScrollingPreviewEnabled: Boolean } \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index f96ee8b723..6c78faf07f 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { implementation(projects.domain.appCurrency.models) implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.analytics) //TODO: Create api/impl modules for onboarding [REDACTED_JIRA] implementation(projects.features.onboarding) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletRouterModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletRouterModule.kt index 1e64fc4122..81c4fd83ef 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletRouterModule.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/di/WalletRouterModule.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.di import com.tangem.core.navigation.ReduxNavController import com.tangem.feature.wallet.presentation.router.DefaultWalletRouter +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.features.wallet.navigation.WalletRouter import dagger.Module import dagger.Provides @@ -15,7 +16,10 @@ internal object WalletRouterModule { @Provides @ActivityScoped - fun provideWalletRouter(reduxNavController: ReduxNavController): WalletRouter { - return DefaultWalletRouter(reduxNavController = reduxNavController) + fun provideWalletRouter( + reduxNavController: ReduxNavController, + walletFeatureToggles: WalletFeatureToggles, + ): WalletRouter { + return DefaultWalletRouter(reduxNavController = reduxNavController, walletFeatureToggles = walletFeatureToggles) } } \ No newline at end of file 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 71696ceba1..09655bdd61 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 @@ -16,4 +16,7 @@ internal class DefaultWalletFeatureToggles( override val isRedesignedScreenEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(name = "REDESIGNED_WALLET_SCREEN_ENABLED") + + override val isWalletsScrollingPreviewEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "WALLETS_SCROLLING_PREVIEW_ENABLED") } \ No newline at end of file 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 646cec18f5..8807297f9f 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 @@ -26,12 +26,18 @@ import com.tangem.feature.wallet.presentation.WalletFragment import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensScreen import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensViewModel import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen +import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreenV2 import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletViewModel +import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletViewModelV2 import com.tangem.features.tokendetails.navigation.TokenDetailsRouter +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import kotlin.properties.Delegates /** Default implementation of wallet feature router */ -internal class DefaultWalletRouter(private val reduxNavController: ReduxNavController) : InnerWalletRouter { +internal class DefaultWalletRouter( + private val reduxNavController: ReduxNavController, + private val walletFeatureToggles: WalletFeatureToggles, +) : InnerWalletRouter { private var navController: NavHostController by Delegates.notNull() private var onFinish: () -> Unit = {} @@ -47,10 +53,20 @@ internal class DefaultWalletRouter(private val reduxNavController: ReduxNavContr startDestination = WalletRoute.Wallet.route, ) { composable(WalletRoute.Wallet.route) { - val viewModel = hiltViewModel().apply { router = this@DefaultWalletRouter } - LocalLifecycleOwner.current.lifecycle.addObserver(viewModel) + if (walletFeatureToggles.isWalletsScrollingPreviewEnabled) { + val viewModel = hiltViewModel().apply { + setWalletRouter(router = this@DefaultWalletRouter) + } - WalletScreen(state = viewModel.uiState) + WalletScreenV2(state = viewModel.uiState.collectAsStateWithLifecycle().value) + } else { + val viewModel = hiltViewModel().apply { + router = this@DefaultWalletRouter + } + LocalLifecycleOwner.current.lifecycle.addObserver(viewModel) + + WalletScreen(state = viewModel.uiState) + } } composable( 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 8d43c5d586..f244b3ffca 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 @@ -2,6 +2,8 @@ package com.tangem.feature.wallet.presentation.wallet.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.OneTimeAnalyticsEvent +import com.tangem.domain.wallets.models.UserWalletId sealed class WalletScreenAnalyticsEvent { @@ -11,6 +13,16 @@ sealed class WalletScreenAnalyticsEvent { error: Throwable? = null, ) : AnalyticsEvent(category = "Basic", event = event, params = params, error = error) { + class WalletToppedUp(userWalletId: UserWalletId, walletType: AnalyticsParam.WalletType) : + Basic( + event = "Topped up", + params = mapOf(AnalyticsParam.CURRENCY to walletType.value), + ), + OneTimeAnalyticsEvent { + + override val oneTimeEventId: String = id + userWalletId.stringValue + } + object WalletOpened : Basic(event = "Wallet Opened") class CardWasScanned(source: AnalyticsParam.ScannedFrom) : Basic( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt new file mode 100644 index 0000000000..666480b385 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt @@ -0,0 +1,108 @@ +package com.tangem.feature.wallet.presentation.wallet.analytics.utils + +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.analytics.CheckIsWalletToppedUpUseCase +import com.tangem.domain.analytics.model.WalletBalanceState +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.NetworkGroup +import com.tangem.domain.tokens.model.TokenList +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent +import dagger.hilt.android.scopes.ViewModelScoped +import java.math.BigDecimal +import javax.inject.Inject + +@ViewModelScoped +internal class TokenListAnalyticsSender @Inject constructor( + private val analyticsEventHandler: AnalyticsEventHandler, + private val checkIsWalletToppedUpUseCase: CheckIsWalletToppedUpUseCase, +) { + + suspend fun send(userWallet: UserWallet, maybeTokenList: Either) { + val tokenList = (maybeTokenList as? Either.Right)?.value ?: return + + sendBalanceLoadedEventIfNeeded(tokenList) + sendToppedUpEventIfNeeded(tokenList, userWallet) + } + + private fun sendBalanceLoadedEventIfNeeded(tokenList: TokenList) { + createCardBalanceState(tokenList)?.let { balanceState -> + analyticsEventHandler.send(event = WalletScreenAnalyticsEvent.Basic.BalanceLoaded(balanceState)) + } + } + + private suspend fun sendToppedUpEventIfNeeded(tokenList: TokenList, userWallet: UserWallet) { + val balanceState = tokenList.toWalletBalanceState() ?: return + + val isWalletToppedUp = checkIsWalletToppedUpUseCase(userWallet.walletId, balanceState) + .getOrElse { return } + + if (isWalletToppedUp) { + val walletType = if (userWallet.isMultiCurrency) { + AnalyticsParam.WalletType.MultiCurrency + } else { + // For single currency wallets with token list, e.g. Noodle + val currency = when (tokenList) { + is TokenList.Empty -> return + is TokenList.GroupedByNetwork -> tokenList.groups.first().currencies.first() + is TokenList.Ungrouped -> tokenList.currencies.first() + } + + AnalyticsParam.WalletType.SingleCurrency(currency.currency.name) + } + + analyticsEventHandler.send(WalletScreenAnalyticsEvent.Basic.WalletToppedUp(userWallet.walletId, walletType)) + } + } + + private fun createCardBalanceState(tokenList: TokenList): AnalyticsParam.CardBalanceState? { + return when (val fiatBalance = tokenList.totalFiatBalance) { + is TokenList.FiatBalance.Failed -> fiatBalance.toCardBalanceState(tokenList) + is TokenList.FiatBalance.Loaded -> fiatBalance.toCardBalanceState() + is TokenList.FiatBalance.Loading -> null + } + } + + @Suppress("UnusedReceiverParameter") + private fun TokenList.FiatBalance.Failed.toCardBalanceState(tokenList: TokenList): AnalyticsParam.CardBalanceState { + val currenciesStatuses = when (tokenList) { + is TokenList.Empty -> emptyList() + is TokenList.GroupedByNetwork -> tokenList.groups.flatMap(NetworkGroup::currencies) + is TokenList.Ungrouped -> tokenList.currencies + } + + return when { + currenciesStatuses.isEmpty() -> AnalyticsParam.CardBalanceState.Empty + currenciesStatuses.any { it.value is CryptoCurrencyStatus.NoQuote } -> { + AnalyticsParam.CardBalanceState.NoRate + } + else -> AnalyticsParam.CardBalanceState.BlockchainError + } + } + + private fun TokenList.FiatBalance.Loaded.toCardBalanceState(): AnalyticsParam.CardBalanceState { + return if (amount > BigDecimal.ZERO) { + AnalyticsParam.CardBalanceState.Full + } else { + AnalyticsParam.CardBalanceState.Empty + } + } + + private fun TokenList.toWalletBalanceState(): WalletBalanceState? { + return when (val balance = totalFiatBalance) { + is TokenList.FiatBalance.Failed -> WalletBalanceState.Error + is TokenList.FiatBalance.Loaded -> { + if (balance.amount > BigDecimal.ZERO) { + WalletBalanceState.ToppedUp + } else { + WalletBalanceState.Empty + } + } + is TokenList.FiatBalance.Loading -> null + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt new file mode 100644 index 0000000000..83355647bd --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -0,0 +1,203 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import arrow.core.Either +import com.tangem.domain.common.CardTypesResolver +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.settings.IsReadyToShowRateAppUseCase +import com.tangem.domain.tokens.GetTokenListUseCase +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.NetworkGroup +import com.tangem.domain.tokens.model.TokenList +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletNotification +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOf +import timber.log.Timber +import javax.inject.Inject + +@ViewModelScoped +internal class GetMultiWalletWarningsFactory @Inject constructor( + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getTokenListUseCase: GetTokenListUseCase, + private val isDemoCardUseCase: IsDemoCardUseCase, + private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, + private val isNeedToBackupUseCase: IsNeedToBackupUseCase, +) { + + private var readyForRateAppNotification = false + + fun create(clickIntents: WalletClickIntentsV2): Flow> { + val userWallet = getSelectedWalletSyncUseCase().fold( + ifLeft = { + Timber.e("Failed to get selected wallet $it") + return flowOf(value = persistentListOf()) + }, + ifRight = { it }, + ) + + val cardTypesResolver = userWallet.scanResponse.cardTypesResolver + + return combine( + flow = getTokenListUseCase(userWallet.walletId).conflate(), + flow2 = isReadyToShowRateAppUseCase().conflate(), + flow3 = isNeedToBackupUseCase(userWallet.walletId).conflate(), + // flow4 = getMissedAddressCryptoCurrenciesUseCase(userWallet.walletId).conflate(), + ) { maybeTokenList, isReadyToShowRating, isNeedToBackup -> + // maybeTokenList.onRight { Timber.e(it.toString()) } + // maybeMissedAddressCurrencies.onRight { Timber.e(it.toString()) } + readyForRateAppNotification = true + buildList { + addCriticalNotifications(cardTypesResolver) + + addInformationalNotifications(cardTypesResolver, maybeTokenList, clickIntents) + + addWarningNotifications(cardTypesResolver, maybeTokenList, isNeedToBackup, clickIntents) + + addRateTheAppNotification(isReadyToShowRating, clickIntents) + }.toImmutableList() + } + } + + private fun MutableList.addCriticalNotifications(cardTypesResolver: CardTypesResolver) { + addIf( + element = WalletNotification.Critical.DevCard, + condition = !cardTypesResolver.isReleaseFirmwareType(), + ) + + addIf( + element = WalletNotification.Critical.FailedCardValidation, + condition = cardTypesResolver.isReleaseFirmwareType() && cardTypesResolver.isAttestationFailed(), + ) + + cardTypesResolver.getRemainingSignatures()?.let { remainingSignatures -> + addIf( + element = WalletNotification.Warning.LowSignatures(count = remainingSignatures), + condition = remainingSignatures <= MAX_REMAINING_SIGNATURES_COUNT, + ) + } + } + + private fun MutableList.addInformationalNotifications( + cardTypesResolver: CardTypesResolver, + maybeTokenList: Either, + clickIntents: WalletClickIntentsV2, + ) { + addIf( + element = WalletNotification.Informational.DemoCard, + condition = isDemoCardUseCase(cardId = cardTypesResolver.getCardId()), + ) + + addMissingAddressesNotification(maybeTokenList, clickIntents) + } + + private fun MutableList.addMissingAddressesNotification( + maybeTokenList: Either, + clickIntents: WalletClickIntentsV2, + ) { + val currencies = maybeTokenList.getMissingAddressCurrencies() + + addIf( + element = WalletNotification.Informational.MissingAddresses( + missingAddressesCount = currencies.count(), + onGenerateClick = { + clickIntents.onGenerateMissedAddressesClick(missedAddressCurrencies = currencies) + }, + ), + condition = currencies.isNotEmpty(), + ) + } + + private fun Either.getMissingAddressCurrencies(): List { + return fold( + ifLeft = { emptyList() }, + ifRight = { tokenList -> + val currencies = when (tokenList) { + is TokenList.GroupedByNetwork -> tokenList.groups.flatMap(NetworkGroup::currencies) + is TokenList.Ungrouped -> tokenList.currencies + is TokenList.Empty -> emptyList() + } + + currencies + .filter { it.value is CryptoCurrencyStatus.MissedDerivation } + .map(CryptoCurrencyStatus::currency) + }, + ) + } + + private fun MutableList.addWarningNotifications( + cardTypesResolver: CardTypesResolver, + tokenList: Either, + isNeedToBackup: Boolean, + clickIntents: WalletClickIntentsV2, + ) { + addIf( + element = WalletNotification.Warning.MissingBackup( + onStartBackupClick = clickIntents::onAddBackupCardClick, + ), + condition = isNeedToBackup, + ) + + addIf( + element = WalletNotification.Warning.TestNetCard, + condition = cardTypesResolver.isTestCard(), + ) + + addIf( + element = WalletNotification.Warning.SomeNetworksUnreachable, + condition = tokenList.hasUnreachableNetworks(), + ) + } + + private fun Either.hasUnreachableNetworks(): Boolean { + return fold( + ifLeft = { false }, + ifRight = { tokenList -> + val currencies = when (tokenList) { + is TokenList.GroupedByNetwork -> tokenList.groups.flatMap(NetworkGroup::currencies) + is TokenList.Ungrouped -> tokenList.currencies + is TokenList.Empty -> emptyList() + } + + currencies.any { it.value is CryptoCurrencyStatus.Unreachable } + }, + ) + } + + private fun MutableList.addRateTheAppNotification( + isReadyToShowRating: Boolean, + clickIntents: WalletClickIntentsV2, + ) { + addIf( + element = WalletNotification.RateApp( + onLikeClick = clickIntents::onLikeAppClick, + onDislikeClick = clickIntents::onDislikeAppClick, + onCloseClick = clickIntents::onCloseRateAppWarningClick, + ), + condition = isReadyToShowRating && readyForRateAppNotification, + ) + } + + private fun MutableList.addIf(element: WalletNotification, condition: Boolean) { + if (condition) { + add(element = element) + if (element is WalletNotification.Critical || element is WalletNotification.Warning) { + readyForRateAppNotification = false + } + } + } + + private companion object { + const val MAX_REMAINING_SIGNATURES_COUNT = 10 + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt new file mode 100644 index 0000000000..a248e07dbe --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt @@ -0,0 +1,184 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import arrow.core.Either +import com.tangem.domain.common.CardTypesResolver +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.settings.IsReadyToShowRateAppUseCase +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.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletNotification +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.* +import timber.log.Timber +import javax.inject.Inject + +@ViewModelScoped +internal class GetSingleWalletWarningsFactory @Inject constructor( + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val isDemoCardUseCase: IsDemoCardUseCase, + private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, + private val isNeedToBackupUseCase: IsNeedToBackupUseCase, + private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase, +) { + + private var readyForRateAppNotification = false + + fun create(clickIntents: WalletClickIntentsV2): Flow> { + val userWallet = getSelectedWalletSyncUseCase().fold( + ifLeft = { + Timber.e("Failed to get selected wallet $it") + return flowOf(value = persistentListOf()) + }, + ifRight = { it }, + ) + + val cardTypesResolver = userWallet.scanResponse.cardTypesResolver + + return combine( + flow = getPrimaryCurrencyStatusUpdatesUseCase(userWallet.walletId), + flow2 = isReadyToShowRateAppUseCase().conflate(), + flow3 = isNeedToBackupUseCase(userWallet.walletId).conflate(), + ) { primaryCurrencyStatus, isReadyToShowRating, isNeedToBackup -> + readyForRateAppNotification = true + buildList { + addCriticalNotifications(cardTypesResolver) + + addInformationalNotifications(cardTypesResolver) + + addWarningNotifications( + userWallet, + cardTypesResolver, + primaryCurrencyStatus, + isNeedToBackup, + clickIntents, + ) + + addRateTheAppNotification(isReadyToShowRating, clickIntents) + }.toImmutableList() + } + } + + private fun MutableList.addCriticalNotifications(cardTypesResolver: CardTypesResolver) { + addIf( + element = WalletNotification.Critical.DevCard, + condition = !cardTypesResolver.isReleaseFirmwareType(), + ) + + addIf( + element = WalletNotification.Critical.FailedCardValidation, + condition = cardTypesResolver.isReleaseFirmwareType() && cardTypesResolver.isAttestationFailed(), + ) + + cardTypesResolver.getRemainingSignatures()?.let { remainingSignatures -> + addIf( + element = WalletNotification.Warning.LowSignatures(count = remainingSignatures), + condition = remainingSignatures <= MAX_REMAINING_SIGNATURES_COUNT, + ) + } + } + + private fun MutableList.addInformationalNotifications(cardTypesResolver: CardTypesResolver) { + addIf( + element = WalletNotification.Informational.DemoCard, + condition = isDemoCardUseCase(cardId = cardTypesResolver.getCardId()), + ) + } + + private suspend fun MutableList.addWarningNotifications( + userWallet: UserWallet, + cardTypesResolver: CardTypesResolver, + maybePrimaryCurrencyStatus: Either, + isNeedToBackup: Boolean, + clickIntents: WalletClickIntentsV2, + ) { + val cryptoCurrencyStatus = maybePrimaryCurrencyStatus.fold(ifLeft = { null }, ifRight = { it }) + + addIf( + element = WalletNotification.Warning.MissingBackup( + onStartBackupClick = clickIntents::onAddBackupCardClick, + ), + condition = isNeedToBackup, + ) + + addIf( + element = WalletNotification.Warning.TestNetCard, + condition = cardTypesResolver.isTestCard(), + ) + + addIf( + element = WalletNotification.Warning.NetworksUnreachable, + condition = cryptoCurrencyStatus?.value is CryptoCurrencyStatus.Unreachable, + ) + + addNoAccountWarning(cryptoCurrencyStatus) + + addIf( + element = WalletNotification.Warning.NumberOfSignedHashesIncorrect( + onCloseClick = clickIntents::onCloseAlreadySignedHashesWarningClick, + ), + condition = hasSignedHashes(userWallet, cryptoCurrencyStatus), + ) + } + + private fun MutableList.addNoAccountWarning(cryptoCurrencyStatus: CryptoCurrencyStatus?) { + val noAccountStatus = cryptoCurrencyStatus?.value as? CryptoCurrencyStatus.NoAccount + if (noAccountStatus != null) { + add( + element = WalletNotification.Informational.NoAccount( + network = cryptoCurrencyStatus.currency.name, + amount = noAccountStatus.amountToCreateAccount.toString(), + symbol = cryptoCurrencyStatus.currency.symbol, + ), + ) + } + } + + private suspend fun hasSignedHashes( + selectedWallet: UserWallet, + cryptoCurrencyStatus: CryptoCurrencyStatus?, + ): Boolean { + return cryptoCurrencyStatus?.currency?.network?.let { + hasSingleWalletSignedHashesUseCase(userWallet = selectedWallet, network = it) + .conflate() + .distinctUntilChanged() + .firstOrNull() + } ?: false + } + + private fun MutableList.addRateTheAppNotification( + isReadyToShowRating: Boolean, + clickIntents: WalletClickIntentsV2, + ) { + addIf( + element = WalletNotification.RateApp( + onLikeClick = clickIntents::onLikeAppClick, + onDislikeClick = clickIntents::onDislikeAppClick, + onCloseClick = clickIntents::onCloseRateAppWarningClick, + ), + condition = isReadyToShowRating && readyForRateAppNotification, + ) + } + + private fun MutableList.addIf(element: WalletNotification, condition: Boolean) { + if (condition) { + add(element = element) + if (element is WalletNotification.Critical || element is WalletNotification.Warning) { + readyForRateAppNotification = false + } + } + } + + private companion object { + const val MAX_REMAINING_SIGNATURES_COUNT = 10 + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UseCaseExt.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UseCaseExt.kt new file mode 100644 index 0000000000..7e62eb79e3 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/UseCaseExt.kt @@ -0,0 +1,60 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import arrow.core.Either +import arrow.core.getOrElse +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +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.models.UserWalletId +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import kotlinx.coroutines.flow.* +import timber.log.Timber + +internal fun GetSelectedWalletSyncUseCase.unwrap(): UserWallet? { + return this().fold( + ifLeft = { + Timber.e("Impossible to get selected wallet $it") + null + }, + ifRight = { it }, + ) +} + +internal suspend fun GetPrimaryCurrencyStatusUpdatesUseCase.unwrap(userWalletId: UserWalletId): CryptoCurrencyStatus? { + return this(userWalletId) + .conflate() + .distinctUntilChanged() + .filter(Either::isRight) + .firstOrNull() + ?.fold( + ifLeft = { + Timber.e("Impossible to get primary currency status $it") + null + }, + ifRight = { it }, + ) +} + +internal suspend fun GetSelectedAppCurrencyUseCase.unwrap(): AppCurrency { + return this() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .firstOrNull() + ?: AppCurrency.Default +} + +internal suspend fun GetPrimaryCurrencyStatusUpdatesUseCase.collectLatest( + userWalletId: UserWalletId, + onRight: suspend (CryptoCurrencyStatus) -> Unit, +) { + this(userWalletId = userWalletId) + .conflate() + .distinctUntilChanged() + .collectLatest { maybeStatus -> + maybeStatus.onRight { onRight(it) } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletWithFundsChecker.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletWithFundsChecker.kt new file mode 100644 index 0000000000..3426a96ae1 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletWithFundsChecker.kt @@ -0,0 +1,38 @@ +package com.tangem.feature.wallet.presentation.wallet.domain + +import arrow.core.Either +import com.tangem.common.extensions.isZero +import com.tangem.domain.settings.SetWalletWithFundsFoundUseCase +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.NetworkGroup +import com.tangem.domain.tokens.model.TokenList +import javax.inject.Inject + +internal class WalletWithFundsChecker @Inject constructor( + private val setWalletWithFundsFoundUseCase: SetWalletWithFundsFoundUseCase, +) { + + suspend fun check(maybeTokenList: Either) { + val tokenList = (maybeTokenList as? Either.Right)?.value ?: return + + val hasNonZeroWallets = when (tokenList) { + is TokenList.GroupedByNetwork -> { + tokenList.groups + .flatMap(NetworkGroup::currencies) + .hasNonZeroWallets() + } + is TokenList.Ungrouped -> tokenList.currencies.hasNonZeroWallets() + is TokenList.Empty -> false + } + + if (hasNonZeroWallets) setWalletWithFundsFoundUseCase() + } + + private fun List.hasNonZeroWallets(): Boolean { + return any { + val amount = it.value.amount ?: return@any false + !amount.isZero() + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletContentLoaderFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletContentLoaderFactory.kt new file mode 100644 index 0000000000..a6d0db40dc --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletContentLoaderFactory.kt @@ -0,0 +1,40 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.loaders.implementors.MultiWalletContentLoaderFactory +import com.tangem.feature.wallet.presentation.wallet.loaders.implementors.SingleWalletContentLoaderFactory +import com.tangem.feature.wallet.presentation.wallet.loaders.implementors.SingleWalletWithTokenContentLoaderFactory +import com.tangem.feature.wallet.presentation.wallet.loaders.implementors.WalletContentLoader +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject + +@ViewModelScoped +internal class WalletContentLoaderFactory @Inject constructor( + private val multiWalletContentLoaderFactory: MultiWalletContentLoaderFactory, + private val singleWalletWithTokenContentLoaderFactory: SingleWalletWithTokenContentLoaderFactory, + private val singleWalletContentLoaderFactory: SingleWalletContentLoaderFactory, +) { + + fun create( + userWallet: UserWallet, + appCurrency: AppCurrency, + clickIntents: WalletClickIntentsV2, + isRefresh: Boolean = false, + ): WalletContentLoader? { + return when { + userWallet.isMultiCurrency -> { + multiWalletContentLoaderFactory.create(userWallet, appCurrency, clickIntents) + } + userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() -> { + singleWalletWithTokenContentLoaderFactory.create(userWallet, appCurrency, clickIntents) + } + !userWallet.isMultiCurrency -> { + singleWalletContentLoaderFactory.create(userWallet, appCurrency, clickIntents, isRefresh) + } + else -> null + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletLoaderStorage.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletLoaderStorage.kt new file mode 100644 index 0000000000..11414466c8 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletLoaderStorage.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders + +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.Job +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class WalletLoaderStorage @Inject constructor() { + + private val loaders = ConcurrentHashMap>() + + fun contains(id: UserWalletId) = loaders.containsKey(id) + + fun set(id: UserWalletId, jobs: List) { + loaders[id] = jobs + } + + fun remove(id: UserWalletId) { + loaders[id]?.let { + it.forEach(Job::cancel) + loaders.remove(id) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletScreenContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletScreenContentLoader.kt new file mode 100644 index 0000000000..24b6e550ed --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/WalletScreenContentLoader.kt @@ -0,0 +1,91 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.CoroutineScope +import timber.log.Timber +import javax.inject.Inject + +/** + * Base wallet screen content loader. Use it to load content by [UserWallet]. + * + * @property factory factory that creates loader + * @property storage storage that save loader's jobs + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +@ViewModelScoped +internal class WalletScreenContentLoader @Inject constructor( + private val factory: WalletContentLoaderFactory, + private val storage: WalletLoaderStorage, + private val dispatchers: CoroutineDispatcherProvider, +) { + + /** + * Load content by [UserWallet] + * + * @param userWallet user wallet + * @param appCurrency app currency + * @param clickIntents click intents + * @param isRefresh flag that determinate if content must load again + * @param coroutineScope coroutine scope + */ + fun load( + userWallet: UserWallet, + appCurrency: AppCurrency, + clickIntents: WalletClickIntentsV2, + isRefresh: Boolean = false, + coroutineScope: CoroutineScope, + ) { + if (userWallet.isLocked) return + + val id = userWallet.walletId + if (!storage.contains(id)) { + loadInternal(userWallet, appCurrency, clickIntents, coroutineScope, isRefresh) + } else { + if (isRefresh) { + storage.remove(id) + loadInternal(userWallet, appCurrency, clickIntents, coroutineScope, true) + } else { + Timber.d("$id content loading has already started") + } + } + } + + /** Cancel loading by [id] */ + fun cancel(id: UserWalletId) { + Timber.d("$id content loading is canceled") + storage.remove(id) + } + + private fun loadInternal( + userWallet: UserWallet, + appCurrency: AppCurrency, + clickIntents: WalletClickIntentsV2, + coroutineScope: CoroutineScope, + isRefresh: Boolean, + ) { + val loader = factory.create( + userWallet = userWallet, + appCurrency = appCurrency, + clickIntents = clickIntents, + isRefresh = isRefresh, + ) + + if (loader == null) { + Timber.e("Impossible to create loader for $userWallet") + return + } + + Timber.d("${userWallet.walletId} content loading is ${if (isRefresh) "re" else ""}started") + + loader.subscribers + .map { it.subscribe(coroutineScope, dispatchers) } + .let { storage.set(userWallet.walletId, it) } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6424208a3f --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt @@ -0,0 +1,46 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders.implementors + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.GetTokenListUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender +import com.tangem.feature.wallet.presentation.wallet.domain.GetMultiWalletWarningsFactory +import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.subscribers.MultiWalletWarningsSubscriber +import com.tangem.feature.wallet.presentation.wallet.subscribers.TokenListSubscriber +import com.tangem.feature.wallet.presentation.wallet.subscribers.WalletSubscriber +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 + +@Suppress("LongParameterList") +internal class MultiWalletContentLoader( + private val userWallet: UserWallet, + private val appCurrency: AppCurrency, + private val clickIntents: WalletClickIntentsV2, + private val stateHolder: WalletStateHolderV2, + private val tokenListAnalyticsSender: TokenListAnalyticsSender, + private val walletWithFundsChecker: WalletWithFundsChecker, + private val getTokenListUseCase: GetTokenListUseCase, + private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, +) : WalletContentLoader(id = userWallet.walletId) { + + override fun create(): List> { + return listOf( + TokenListSubscriber( + userWallet = userWallet, + appCurrency = appCurrency, + stateHolder = stateHolder, + clickIntents = clickIntents, + tokenListAnalyticsSender = tokenListAnalyticsSender, + walletWithFundsChecker = walletWithFundsChecker, + getTokenListUseCase = getTokenListUseCase, + ), + MultiWalletWarningsSubscriber( + userWalletId = userWallet.walletId, + stateHolder = stateHolder, + clickIntents = clickIntents, + getMultiWalletWarningsFactory = getMultiWalletWarningsFactory, + ), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..1751d80a8b --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoaderFactory.kt @@ -0,0 +1,39 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders.implementors + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.GetTokenListUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender +import com.tangem.feature.wallet.presentation.wallet.domain.GetMultiWalletWarningsFactory +import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject + +@ViewModelScoped +internal class MultiWalletContentLoaderFactory @Inject constructor( + private val stateHolder: WalletStateHolderV2, + private val getTokenListUseCase: GetTokenListUseCase, + private val tokenListAnalyticsSender: TokenListAnalyticsSender, + private val walletWithFundsChecker: WalletWithFundsChecker, + private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, +) { + + fun create( + userWallet: UserWallet, + appCurrency: AppCurrency, + clickIntents: WalletClickIntentsV2, + ): WalletContentLoader { + return MultiWalletContentLoader( + userWallet = userWallet, + appCurrency = appCurrency, + clickIntents = clickIntents, + stateHolder = stateHolder, + tokenListAnalyticsSender = tokenListAnalyticsSender, + walletWithFundsChecker = walletWithFundsChecker, + getTokenListUseCase = getTokenListUseCase, + getMultiWalletWarningsFactory = getMultiWalletWarningsFactory, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletContentLoader.kt new file mode 100644 index 0000000000..792c45ee35 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletContentLoader.kt @@ -0,0 +1,66 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders.implementors + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.settings.SetWalletWithFundsFoundUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.domain.GetSingleWalletWarningsFactory +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.subscribers.* +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 + +@Suppress("LongParameterList") +internal class SingleWalletContentLoader( + private val userWallet: UserWallet, + private val appCurrency: AppCurrency, + private val clickIntents: WalletClickIntentsV2, + private val isRefresh: Boolean, + private val stateHolder: WalletStateHolderV2, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, + private val getSingleWalletWarningsFactory: GetSingleWalletWarningsFactory, + private val setWalletWithFundsFoundUseCase: SetWalletWithFundsFoundUseCase, + private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, +) : WalletContentLoader(id = userWallet.walletId) { + + override fun create(): List> { + return listOf( + PrimaryCurrencySubscriber( + userWallet = userWallet, + appCurrency = appCurrency, + stateHolder = stateHolder, + getPrimaryCurrencyStatusUpdatesUseCase = getPrimaryCurrencyStatusUpdatesUseCase, + setWalletWithFundsFoundUseCase = setWalletWithFundsFoundUseCase, + analyticsEventHandler = analyticsEventHandler, + ), + SingleWalletButtonsSubscriber( + userWallet = userWallet, + stateHolder = stateHolder, + clickIntents = clickIntents, + getPrimaryCurrencyStatusUpdatesUseCase = getPrimaryCurrencyStatusUpdatesUseCase, + getCryptoCurrencyActionsUseCase = getCryptoCurrencyActionsUseCase, + ), + SingleWalletNotificationsSubscriber( + userWalletId = userWallet.walletId, + stateHolder = stateHolder, + clickIntents = clickIntents, + getSingleWalletWarningsFactory = getSingleWalletWarningsFactory, + ), + TxHistorySubscriber( + userWallet = userWallet, + isRefresh = isRefresh, + stateHolder = stateHolder, + clickIntents = clickIntents, + getPrimaryCurrencyStatusUpdatesUseCase = getPrimaryCurrencyStatusUpdatesUseCase, + txHistoryItemsCountUseCase = txHistoryItemsCountUseCase, + txHistoryItemsUseCase = txHistoryItemsUseCase, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletContentLoaderFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletContentLoaderFactory.kt new file mode 100644 index 0000000000..05eb03ee25 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletContentLoaderFactory.kt @@ -0,0 +1,51 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders.implementors + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.settings.SetWalletWithFundsFoundUseCase +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.domain.GetSingleWalletWarningsFactory +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Inject + +@ViewModelScoped +@Suppress("LongParameterList") +internal class SingleWalletContentLoaderFactory @Inject constructor( + private val stateHolder: WalletStateHolderV2, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, + private val getSingleWalletWarningsFactory: GetSingleWalletWarningsFactory, + private val setWalletWithFundsFoundUseCase: SetWalletWithFundsFoundUseCase, + private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, +) { + + fun create( + userWallet: UserWallet, + appCurrency: AppCurrency, + clickIntents: WalletClickIntentsV2, + isRefresh: Boolean, + ): WalletContentLoader { + return SingleWalletContentLoader( + userWallet = userWallet, + appCurrency = appCurrency, + clickIntents = clickIntents, + isRefresh = isRefresh, + stateHolder = stateHolder, + getPrimaryCurrencyStatusUpdatesUseCase = getPrimaryCurrencyStatusUpdatesUseCase, + getCryptoCurrencyActionsUseCase = getCryptoCurrencyActionsUseCase, + getSingleWalletWarningsFactory = getSingleWalletWarningsFactory, + setWalletWithFundsFoundUseCase = setWalletWithFundsFoundUseCase, + txHistoryItemsCountUseCase = txHistoryItemsCountUseCase, + txHistoryItemsUseCase = txHistoryItemsUseCase, + analyticsEventHandler = analyticsEventHandler, + ) + } +} \ 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 new file mode 100644 index 0000000000..fd3242278a --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoader.kt @@ -0,0 +1,46 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders.implementors + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.GetCardTokensListUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender +import com.tangem.feature.wallet.presentation.wallet.domain.GetMultiWalletWarningsFactory +import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.subscribers.MultiWalletWarningsSubscriber +import com.tangem.feature.wallet.presentation.wallet.subscribers.SingleWalletWithTokenListSubscriber +import com.tangem.feature.wallet.presentation.wallet.subscribers.WalletSubscriber +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 + +@Suppress("LongParameterList") +internal class SingleWalletWithTokenContentLoader( + private val userWallet: UserWallet, + private val appCurrency: AppCurrency, + private val clickIntents: WalletClickIntentsV2, + private val stateHolder: WalletStateHolderV2, + private val tokenListAnalyticsSender: TokenListAnalyticsSender, + private val walletWithFundsChecker: WalletWithFundsChecker, + private val getCardTokensListUseCase: GetCardTokensListUseCase, + private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, +) : WalletContentLoader(id = userWallet.walletId) { + + override fun create(): List> { + return listOf( + SingleWalletWithTokenListSubscriber( + userWallet = userWallet, + appCurrency = appCurrency, + stateHolder = stateHolder, + clickIntents = clickIntents, + tokenListAnalyticsSender = tokenListAnalyticsSender, + walletWithFundsChecker = walletWithFundsChecker, + getCardTokensListUseCase = getCardTokensListUseCase, + ), + MultiWalletWarningsSubscriber( + userWalletId = userWallet.walletId, + stateHolder = stateHolder, + clickIntents = clickIntents, + getMultiWalletWarningsFactory = getMultiWalletWarningsFactory, + ), + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..efef9eeb8d --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/SingleWalletWithTokenContentLoaderFactory.kt @@ -0,0 +1,37 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders.implementors + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.GetCardTokensListUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender +import com.tangem.feature.wallet.presentation.wallet.domain.GetMultiWalletWarningsFactory +import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import javax.inject.Inject + +internal class SingleWalletWithTokenContentLoaderFactory @Inject constructor( + private val stateHolder: WalletStateHolderV2, + private val tokenListAnalyticsSender: TokenListAnalyticsSender, + private val walletWithFundsChecker: WalletWithFundsChecker, + private val getCardTokensListUseCase: GetCardTokensListUseCase, + private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, +) { + + fun create( + userWallet: UserWallet, + appCurrency: AppCurrency, + clickIntents: WalletClickIntentsV2, + ): SingleWalletWithTokenContentLoader { + return SingleWalletWithTokenContentLoader( + userWallet = userWallet, + appCurrency = appCurrency, + clickIntents = clickIntents, + stateHolder = stateHolder, + tokenListAnalyticsSender = tokenListAnalyticsSender, + walletWithFundsChecker = walletWithFundsChecker, + getCardTokensListUseCase = getCardTokensListUseCase, + getMultiWalletWarningsFactory = getMultiWalletWarningsFactory, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/WalletContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/WalletContentLoader.kt new file mode 100644 index 0000000000..83f9c28342 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/WalletContentLoader.kt @@ -0,0 +1,19 @@ +package com.tangem.feature.wallet.presentation.wallet.loaders.implementors + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.subscribers.WalletSubscriber + +/** + * Wallet content loader + * + * @property id loader id + * +[REDACTED_AUTHOR] + */ +internal abstract class WalletContentLoader(val id: UserWalletId) { + + /** Loader's subscribers */ + val subscribers: List> get() = create() + + protected abstract fun create(): List> +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletEvent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletEvent.kt index 9c619d9a9c..a269d87651 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletEvent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletEvent.kt @@ -17,4 +17,16 @@ internal sealed class WalletEvent { data class CopyAddress(val address: String, val toast: TextReference) : WalletEvent() data class RateApp(val onDismissClick: () -> Unit) : WalletEvent() + + data class DemonstrateWalletsScrollPreview(val direction: Direction) : WalletEvent() { + + enum class Direction { + + /** 1 -> 2 */ + LEFT, + + /** 1 <- 2 */ + RIGHT, + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/WalletScreenState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/WalletScreenState.kt new file mode 100644 index 0000000000..740d54798c --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/WalletScreenState.kt @@ -0,0 +1,187 @@ +package com.tangem.feature.wallet.presentation.wallet.state2 + +import androidx.paging.PagingData +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.common.state.TokenItemState +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.state.components.* +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import javax.annotation.concurrent.Immutable + +const val NOT_INITIALIZED_WALLET_INDEX = -1 + +internal data class WalletScreenState( + val onBackClick: () -> Unit, + val topBarConfig: WalletTopBarConfig, + val selectedWalletIndex: Int, + val wallets: ImmutableList, + val onWalletChange: (Int) -> Unit, + val event: StateEvent, + val isHidingMode: Boolean, +) + +internal sealed class WalletState { + + abstract val pullToRefreshConfig: WalletPullToRefreshConfig + abstract val walletCardState: WalletCardState + abstract val warnings: ImmutableList + abstract val bottomSheetConfig: TangemBottomSheetConfig? + + sealed class MultiCurrency : WalletState() { + + abstract val tokensListState: WalletTokensListState + abstract val manageTokensButtonConfig: ManageTokensButtonConfig? + + data class Content( + override val pullToRefreshConfig: WalletPullToRefreshConfig, + override val walletCardState: WalletCardState, + override val warnings: ImmutableList, + override val bottomSheetConfig: TangemBottomSheetConfig?, + override val tokensListState: WalletTokensListState, + override val manageTokensButtonConfig: ManageTokensButtonConfig?, + ) : MultiCurrency() + + data class Locked( + override val walletCardState: WalletCardState, + val onUnlockNotificationClick: () -> Unit, + val isBottomSheetShow: Boolean = false, + val onBottomSheetDismiss: () -> Unit = {}, + val onUnlockClick: () -> Unit, + val onScanClick: () -> Unit, + ) : MultiCurrency() { + + override val pullToRefreshConfig: WalletPullToRefreshConfig + get() = WalletPullToRefreshConfig(isRefreshing = false, onRefresh = {}) + + override val warnings: ImmutableList = persistentListOf( + WalletNotification.UnlockWallets(onUnlockNotificationClick), + ) + + override val bottomSheetConfig = TangemBottomSheetConfig( + isShow = isBottomSheetShow, + onDismissRequest = onBottomSheetDismiss, + content = WalletBottomSheetConfig.UnlockWallets( + onUnlockClick = onUnlockClick, + onScanClick = onScanClick, + ), + ) + + override val tokensListState = WalletTokensListState.ContentState.Locked + override val manageTokensButtonConfig = null + } + } + + sealed class SingleCurrency : WalletState() { + + abstract val buttons: PersistentList + abstract val marketPriceBlockState: MarketPriceBlockState? + abstract val txHistoryState: TxHistoryState + + data class Content( + override val pullToRefreshConfig: WalletPullToRefreshConfig, + override val walletCardState: WalletCardState, + override val warnings: ImmutableList, + override val bottomSheetConfig: TangemBottomSheetConfig?, + override val buttons: PersistentList, + override val marketPriceBlockState: MarketPriceBlockState, + override val txHistoryState: TxHistoryState, + ) : SingleCurrency() + + data class Locked( + override val walletCardState: WalletCardState, + override val buttons: PersistentList, + val onUnlockNotificationClick: () -> Unit, + val isBottomSheetShow: Boolean = false, + val onBottomSheetDismiss: () -> Unit = {}, + val onUnlockClick: () -> Unit, + val onScanClick: () -> Unit, + val onExploreClick: () -> Unit, + ) : SingleCurrency() { + + override val pullToRefreshConfig: WalletPullToRefreshConfig + get() = WalletPullToRefreshConfig(isRefreshing = false, onRefresh = {}) + + override val warnings: ImmutableList = persistentListOf( + WalletNotification.UnlockWallets(onUnlockNotificationClick), + ) + + override val bottomSheetConfig = TangemBottomSheetConfig( + isShow = isBottomSheetShow, + onDismissRequest = onBottomSheetDismiss, + content = WalletBottomSheetConfig.UnlockWallets( + onUnlockClick = onUnlockClick, + onScanClick = onScanClick, + ), + ) + + override val marketPriceBlockState: MarketPriceBlockState? = null + + override val txHistoryState: TxHistoryState = TxHistoryState.Content( + contentItems = MutableStateFlow( + value = PagingData.from( + data = listOf( + TxHistoryState.TxHistoryItemState.Title(onExploreClick = onExploreClick), + TxHistoryState.TxHistoryItemState.Transaction( + state = TransactionState.Locked(txHash = "LOCKED_TX_HASH"), + ), + ), + ), + ), + ) + } + } +} + +internal sealed class WalletTokensListState { + + object Empty : WalletTokensListState() + + sealed class ContentState : WalletTokensListState() { + + abstract val items: ImmutableList + abstract val organizeTokensButtonConfig: OrganizeTokensButtonConfig? + + object Loading : ContentState() { + override val items = persistentListOf() + override val organizeTokensButtonConfig = null + } + + data class Content( + override val items: ImmutableList, + override val organizeTokensButtonConfig: OrganizeTokensButtonConfig?, + ) : ContentState() + + object Locked : ContentState() { + override val items = persistentListOf( + TokensListItemState.NetworkGroupTitle(id = 42, name = TextReference.Res(id = R.string.main_tokens)), + TokensListItemState.Token(state = TokenItemState.Locked(id = "Locked#1")), + ) + override val organizeTokensButtonConfig = null + } + } + + data class OrganizeTokensButtonConfig(val isEnabled: Boolean, val onClick: () -> Unit) + + @Immutable + sealed class TokensListItemState { + + abstract val id: Any + + data class NetworkGroupTitle(override val id: Int, val name: TextReference) : TokensListItemState() + + data class Token(val state: TokenItemState) : TokensListItemState() { + override val id: String = state.id + } + } +} + +internal data class ManageTokensButtonConfig(val onClick: () -> Unit) \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/WalletStateHolderV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/WalletStateHolderV2.kt new file mode 100644 index 0000000000..c956da9c5d --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/WalletStateHolderV2.kt @@ -0,0 +1,54 @@ +package com.tangem.feature.wallet.presentation.wallet.state2 + +import com.tangem.core.ui.event.consumedEvent +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTopBarConfig +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.WalletScreenStateTransformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Wallet state holder + * +[REDACTED_AUTHOR] + */ +@Singleton +internal class WalletStateHolderV2 @Inject constructor() { + + val uiState: StateFlow get() = mutableUiState + val value: WalletScreenState get() = uiState.value + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + fun update(function: (WalletScreenState) -> WalletScreenState) { + mutableUiState.update(function = function) + } + + fun update(transformer: WalletScreenStateTransformer) { + mutableUiState.update(function = transformer::transform) + } + + fun getSelectedWallet(): WalletState { + return with(value) { wallets[selectedWalletIndex] } + } + + fun getSelectedWalletId(): UserWalletId { + return with(value) { wallets[selectedWalletIndex].walletCardState.id } + } + + private fun getInitialState(): WalletScreenState { + return WalletScreenState( + onBackClick = {}, + topBarConfig = WalletTopBarConfig(onDetailsClick = {}), + selectedWalletIndex = NOT_INITIALIZED_WALLET_INDEX, + wallets = persistentListOf(), + onWalletChange = {}, + event = consumedEvent(), + isHidingMode = false, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/AddWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/AddWalletTransformer.kt new file mode 100644 index 0000000000..437c8d5e41 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/AddWalletTransformer.kt @@ -0,0 +1,23 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.utils.WalletLoadingStateFactory +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.toImmutableList + +internal class AddWalletTransformer( + private val userWallet: UserWallet, + private val clickIntents: WalletClickIntentsV2, +) : WalletScreenStateTransformer { + + private val walletLoadingStateFactory by lazy { + WalletLoadingStateFactory(clickIntents = clickIntents) + } + + override fun transform(prevState: WalletScreenState): WalletScreenState { + return prevState.copy( + wallets = (prevState.wallets + walletLoadingStateFactory.create(userWallet)).toImmutableList(), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/CloseBottomSheetTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/CloseBottomSheetTransformer.kt new file mode 100644 index 0000000000..37ddf91e8c --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/CloseBottomSheetTransformer.kt @@ -0,0 +1,20 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState + +internal class CloseBottomSheetTransformer(userWalletId: UserWalletId) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.MultiCurrency.Content -> { + prevState.copy(bottomSheetConfig = prevState.bottomSheetConfig?.copy(isShow = false)) + } + is WalletState.MultiCurrency.Locked -> prevState.copy(isBottomSheetShow = false) + is WalletState.SingleCurrency.Content -> { + prevState.copy(bottomSheetConfig = prevState.bottomSheetConfig?.copy(isShow = false)) + } + is WalletState.SingleCurrency.Locked -> prevState.copy(isBottomSheetShow = false) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/DeleteWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/DeleteWalletTransformer.kt new file mode 100644 index 0000000000..34b8081a57 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/DeleteWalletTransformer.kt @@ -0,0 +1,31 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber + +internal class DeleteWalletTransformer( + private val selectedWalletIndex: Int, + private val deletedWalletId: UserWalletId, +) : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState { + val deletedWalletState = prevState.getDeletedWalletState() + + if (deletedWalletState == null) { + Timber.e("Wallets does not contain deleted wallet") + return prevState + } + + return prevState.copy( + selectedWalletIndex = selectedWalletIndex, + wallets = (prevState.wallets - deletedWalletState).toImmutableList(), + ) + } + + private fun WalletScreenState.getDeletedWalletState(): WalletState? { + return wallets.firstOrNull { it.walletCardState.id == deletedWalletId } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/InitializeWalletsTransformer.kt new file mode 100644 index 0000000000..439a633944 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/InitializeWalletsTransformer.kt @@ -0,0 +1,97 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWallet +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.components.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletManageButton +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTopBarConfig +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.utils.WalletLoadingStateFactory +import com.tangem.feature.wallet.presentation.wallet.state2.utils.createStateByWalletType +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class InitializeWalletsTransformer( + private val selectedWalletIndex: Int, + private val selectedWallet: UserWallet, + private val wallets: List, + private val clickIntents: WalletClickIntentsV2, +) : WalletScreenStateTransformer { + + private val walletLoadingStateFactory by lazy { WalletLoadingStateFactory(clickIntents = clickIntents) } + + override fun transform(prevState: WalletScreenState): WalletScreenState { + return prevState.copy( + onBackClick = clickIntents::onBackClick, + topBarConfig = createTopBarConfig(userWallet = selectedWallet), + selectedWalletIndex = selectedWalletIndex, + wallets = wallets + .map { userWallet -> + if (userWallet.isLocked) { + createLockedState(userWallet) + } else { + walletLoadingStateFactory.create(userWallet) + } + } + .toImmutableList(), + onWalletChange = clickIntents::onWalletChange, + ) + } + + private fun createTopBarConfig(userWallet: UserWallet): WalletTopBarConfig { + return WalletTopBarConfig( + onDetailsClick = if (userWallet.isLocked) { + clickIntents::onOpenUnlockWalletsBottomSheetClick + } else { + clickIntents::onDetailsClick + }, + ) + } + + private fun createLockedState(userWallet: UserWallet): WalletState { + return userWallet.createStateByWalletType( + multiCurrencyCreator = { + WalletState.MultiCurrency.Locked( + walletCardState = userWallet.toLockedWalletCardState(), + onUnlockNotificationClick = clickIntents::onOpenUnlockWalletsBottomSheetClick, + onUnlockClick = clickIntents::onUnlockWalletClick, + onScanClick = clickIntents::onScanToUnlockWalletClick, + ) + }, + singleCurrencyCreator = { + WalletState.SingleCurrency.Locked( + walletCardState = userWallet.toLockedWalletCardState(), + buttons = createDisabledButtons(), + onUnlockNotificationClick = clickIntents::onOpenUnlockWalletsBottomSheetClick, + onUnlockClick = clickIntents::onUnlockWalletClick, + onScanClick = clickIntents::onScanToUnlockWalletClick, + onExploreClick = clickIntents::onExploreClick, + ) + }, + ) + } + + private fun UserWallet.toLockedWalletCardState(): WalletCardState { + return WalletCardState.LockedContent( + id = walletId, + title = name, + additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = this), + imageResId = WalletImageResolver.resolve(userWallet = this), + onRenameClick = clickIntents::onRenameClick, + onDeleteClick = clickIntents::onDeleteBeforeConfirmationClick, + ) + } + + private fun createDisabledButtons(): PersistentList { + return persistentListOf( + WalletManageButton.Buy(enabled = false, onClick = {}), + WalletManageButton.Send(enabled = false, onClick = {}), + WalletManageButton.Receive(enabled = false, onClick = {}), + WalletManageButton.Sell(enabled = false, onClick = {}), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/OpenBottomSheetTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/OpenBottomSheetTransformer.kt new file mode 100644 index 0000000000..f48797a50f --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/OpenBottomSheetTransformer.kt @@ -0,0 +1,42 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState + +internal class OpenBottomSheetTransformer( + userWalletId: UserWalletId, + private val content: TangemBottomSheetConfigContent, + private val onDismissBottomSheet: () -> Unit, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.MultiCurrency.Content -> { + prevState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismissBottomSheet, + content = content, + ), + ) + } + is WalletState.MultiCurrency.Locked -> { + prevState.copy(isBottomSheetShow = true, onBottomSheetDismiss = onDismissBottomSheet) + } + is WalletState.SingleCurrency.Content -> { + prevState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = onDismissBottomSheet, + content = content, + ), + ) + } + is WalletState.SingleCurrency.Locked -> { + prevState.copy(isBottomSheetShow = true, onBottomSheetDismiss = onDismissBottomSheet) + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/ReinitializeWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/ReinitializeWalletTransformer.kt new file mode 100644 index 0000000000..ad0ef39b08 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/ReinitializeWalletTransformer.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.utils.WalletLoadingStateFactory +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.persistentListOf + +/** +[REDACTED_AUTHOR] + */ +internal class ReinitializeWalletTransformer( + private val userWallet: UserWallet, + private val clickIntents: WalletClickIntentsV2, +) : WalletScreenStateTransformer { + + private val walletLoadingStateFactory by lazy { WalletLoadingStateFactory(clickIntents = clickIntents) } + + override fun transform(prevState: WalletScreenState): WalletScreenState { + return prevState.copy( + wallets = persistentListOf( + walletLoadingStateFactory.create(userWallet), + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/RenameWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/RenameWalletTransformer.kt new file mode 100644 index 0000000000..80b3692e93 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/RenameWalletTransformer.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import timber.log.Timber + +internal class RenameWalletTransformer( + userWalletId: UserWalletId, + private val newName: String, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.MultiCurrency.Content -> { + prevState.copy(walletCardState = prevState.walletCardState.copySealed(title = newName)) + } + is WalletState.SingleCurrency.Content -> { + prevState.copy(walletCardState = prevState.walletCardState.copySealed(title = newName)) + } + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to rename wallet in locked state") + prevState + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/ScrollToWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/ScrollToWalletTransformer.kt new file mode 100644 index 0000000000..17c76d98e5 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/ScrollToWalletTransformer.kt @@ -0,0 +1,30 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.common.Provider +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState + +internal class ScrollToWalletTransformer( + private val index: Int, + private val currentStateProvider: Provider, + private val stateUpdater: (WalletScreenState) -> Unit, +) : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState { + return prevState.copy( + event = triggeredEvent( + data = WalletEvent.ChangeWallet(index), + onConsume = { + stateUpdater( + currentStateProvider().copy( + selectedWalletIndex = index, + event = consumedEvent(), + ), + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SendEventTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SendEventTransformer.kt new file mode 100644 index 0000000000..f34236635b --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SendEventTransformer.kt @@ -0,0 +1,17 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.core.ui.event.triggeredEvent +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState + +internal class SendEventTransformer( + private val event: WalletEvent, + private val onConsume: () -> Unit, +) : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState { + return prevState.copy( + event = triggeredEvent(data = event, onConsume = onConsume), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetCryptoCurrencyActionsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetCryptoCurrencyActionsTransformer.kt new file mode 100644 index 0000000000..605e40f98a --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetCryptoCurrencyActionsTransformer.kt @@ -0,0 +1,81 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletManageButton +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList +import timber.log.Timber + +internal class SetCryptoCurrencyActionsTransformer( + private val tokenActionsState: TokenActionsState, + private val userWallet: UserWallet, + private val clickIntents: WalletClickIntentsV2, +) : WalletStateTransformer(userWallet.walletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.SingleCurrency.Content -> { + prevState.copy(buttons = tokenActionsState.toManageButtons()) + } + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to load primary currency status for locked wallet") + prevState + } + is WalletState.MultiCurrency, + -> { + Timber.e("Impossible to load crypto currency actions for multi-currency wallet") + prevState + } + } + } + + private fun TokenActionsState.toManageButtons(): PersistentList { + return states + .filterIfS2C() + .mapNotNull { action -> + when (action) { + is TokenActionsState.ActionState.Buy -> { + WalletManageButton.Buy( + enabled = action.enabled, + onClick = { clickIntents.onBuyClick(cryptoCurrencyStatus) }, + ) + } + is TokenActionsState.ActionState.Receive -> { + WalletManageButton.Receive( + enabled = action.enabled, + onClick = { clickIntents.onReceiveClick(cryptoCurrencyStatus) }, + ) + } + is TokenActionsState.ActionState.Sell -> { + WalletManageButton.Sell( + enabled = action.enabled, + onClick = { clickIntents.onSellClick(cryptoCurrencyStatus) }, + ) + } + is TokenActionsState.ActionState.Send -> { + WalletManageButton.Send( + enabled = action.enabled, + onClick = { clickIntents.onSendClick(cryptoCurrencyStatus) }, + ) + } + else -> { + null + } + } + } + .toPersistentList() + } + + private fun List.filterIfS2C(): List { + return if (userWallet.scanResponse.cardTypesResolver.isStart2Coin()) { + filterNot { it is TokenActionsState.ActionState.Buy || it is TokenActionsState.ActionState.Sell } + } else { + this + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetPrimaryCurrencyTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetPrimaryCurrencyTransformer.kt new file mode 100644 index 0000000000..83f89adc93 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetPrimaryCurrencyTransformer.kt @@ -0,0 +1,47 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter.SingleWalletCardStateConverter +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter.SingleWalletMarketPriceConverter +import timber.log.Timber + +internal class SetPrimaryCurrencyTransformer( + private val userWallet: UserWallet, + private val status: CryptoCurrencyStatus.Status, + private val appCurrency: AppCurrency, +) : WalletStateTransformer(userWallet.walletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.SingleCurrency.Content -> { + prevState.copy( + walletCardState = prevState.walletCardState.toLoadedState(), + marketPriceBlockState = prevState.marketPriceBlockState.toLoadedState(), + ) + } + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to load primary currency status for locked wallet") + prevState + } + is WalletState.MultiCurrency, + -> { + Timber.e("Impossible to load primary currency status for multi-currency wallet") + prevState + } + } + } + + private fun WalletCardState.toLoadedState(): WalletCardState { + return SingleWalletCardStateConverter(status, userWallet, appCurrency).convert(value = this) + } + + private fun MarketPriceBlockState.toLoadedState(): MarketPriceBlockState { + return SingleWalletMarketPriceConverter(status, appCurrency).convert(value = this) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetRefreshStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetRefreshStateTransformer.kt new file mode 100644 index 0000000000..043c611ccd --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetRefreshStateTransformer.kt @@ -0,0 +1,67 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletManageButton +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletPullToRefreshConfig +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.mutate + +internal class SetRefreshStateTransformer( + userWalletId: UserWalletId, + private val isRefreshing: Boolean, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.MultiCurrency.Content -> { + prevState.copy( + pullToRefreshConfig = prevState.pullToRefreshConfig.toUpdatedState(isRefreshing), + tokensListState = prevState.tokensListState.toUpdatedState(), + ) + } + is WalletState.SingleCurrency.Content -> { + prevState.copy( + pullToRefreshConfig = prevState.pullToRefreshConfig.toUpdatedState(isRefreshing), + buttons = prevState.buttons.toUpdatedState(), + ) + } + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + -> prevState + } + } + + private fun WalletPullToRefreshConfig.toUpdatedState(isRefreshing: Boolean): WalletPullToRefreshConfig { + return copy(isRefreshing = isRefreshing) + } + + private fun WalletTokensListState.toUpdatedState(): WalletTokensListState { + return if (this is WalletTokensListState.ContentState.Content && organizeTokensButtonConfig != null) { + copy( + organizeTokensButtonConfig = organizeTokensButtonConfig.copy( + isEnabled = !isRefreshing, + ), + ) + } else { + this + } + } + + private fun PersistentList.toUpdatedState(): PersistentList { + val isButtonsEnabled = !isRefreshing + + return mutate { + it.mapNotNull { button -> + when (button) { + is WalletManageButton.Buy -> button.copy(enabled = isButtonsEnabled) + is WalletManageButton.Send -> button.copy(enabled = isButtonsEnabled) + is WalletManageButton.Sell -> button.copy(enabled = isButtonsEnabled) + is WalletManageButton.Receive -> button + is WalletManageButton.Swap -> null + } + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTokenListErrorTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTokenListErrorTransformer.kt new file mode 100644 index 0000000000..4586c30ab6 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTokenListErrorTransformer.kt @@ -0,0 +1,38 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState +import timber.log.Timber + +internal class SetTokenListErrorTransformer( + userWalletId: UserWalletId, + private val error: TokenListError, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (error) { + is TokenListError.EmptyTokens -> { + when (prevState) { + is WalletState.MultiCurrency.Content -> { + prevState.copy(tokensListState = WalletTokensListState.Empty) + } + is WalletState.MultiCurrency.Locked, + -> { + Timber.e("Impossible to load tokens list for locked wallet") + prevState + } + is WalletState.SingleCurrency, + -> { + Timber.e("Impossible to load tokens list for single-currency wallet") + prevState + } + } + } + is TokenListError.DataError, + is TokenListError.UnableToSortTokenList, + -> prevState + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTokenListTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTokenListTransformer.kt new file mode 100644 index 0000000000..f982dff23f --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTokenListTransformer.kt @@ -0,0 +1,69 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.tokens.model.TokenList +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state2.ManageTokensButtonConfig +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter.MultiWalletCardStateConverter +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter.TokenListStateConverter +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import timber.log.Timber + +internal class SetTokenListTransformer( + private val tokenList: TokenList, + private val userWallet: UserWallet, + private val appCurrency: AppCurrency, + private val clickIntents: WalletClickIntentsV2, +) : WalletStateTransformer(userWallet.walletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.MultiCurrency.Content -> { + prevState.copy( + walletCardState = prevState.walletCardState.toLoadedState(), + tokensListState = prevState.tokensListState.toLoadedState(), + manageTokensButtonConfig = createManageTokensButtonConfig(), + ) + } + is WalletState.MultiCurrency.Locked, + -> { + Timber.e("Impossible to load tokens list for locked wallet") + prevState + } + is WalletState.SingleCurrency, + -> { + Timber.e("Impossible to load tokens list for single-currency wallet") + prevState + } + } + } + + private fun WalletCardState.toLoadedState(): WalletCardState { + return MultiWalletCardStateConverter( + fiatBalance = tokenList.totalFiatBalance, + selectedWallet = userWallet, + appCurrency = appCurrency, + ).convert(value = this) + } + + private fun WalletTokensListState.toLoadedState(): WalletTokensListState { + return TokenListStateConverter( + tokenList = tokenList, + selectedWallet = userWallet, + appCurrency = appCurrency, + clickIntents = clickIntents, + ).convert(value = this) + } + + private fun createManageTokensButtonConfig(): ManageTokensButtonConfig? { + return if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) { + null + } else { + ManageTokensButtonConfig(clickIntents::onManageTokensClick) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryCountErrorTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryCountErrorTransformer.kt new file mode 100644 index 0000000000..26f55b70a4 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryCountErrorTransformer.kt @@ -0,0 +1,68 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter.TxHistoryItemStateConverter +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber + +internal class SetTxHistoryCountErrorTransformer( + private val userWallet: UserWallet, + private val error: TxHistoryStateError, + private val pendingTransactions: Set, + private val clickIntents: WalletClickIntentsV2, +) : WalletStateTransformer(userWallet.walletId) { + + private val txHistoryItemConverter by lazy { + val blockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain() + TxHistoryItemStateConverter( + symbol = blockchain.currency, + decimals = blockchain.decimals(), + clickIntents = clickIntents, + ) + } + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.SingleCurrency.Content -> prevState.toErrorState() + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to load transactions history for locked wallet") + prevState + } + is WalletState.MultiCurrency, + -> { + Timber.e("Impossible to load transactions history for multi-currency wallet") + prevState + } + } + } + + private fun WalletState.SingleCurrency.Content.toErrorState(): WalletState { + return copy( + txHistoryState = when (error) { + is TxHistoryStateError.EmptyTxHistories -> { + TxHistoryState.Empty(onExploreClick = clickIntents::onExploreClick) + } + is TxHistoryStateError.DataError -> { + TxHistoryState.Error( + onReloadClick = clickIntents::onReloadClick, + onExploreClick = clickIntents::onExploreClick, + ) + } + is TxHistoryStateError.TxHistoryNotImplemented -> { + TxHistoryState.NotSupported( + pendingTransactions = txHistoryItemConverter.convertList(pendingTransactions) + .toImmutableList(), + onExploreClick = clickIntents::onExploreClick, + ) + } + }, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryCountTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryCountTransformer.kt new file mode 100644 index 0000000000..082d5aa124 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryCountTransformer.kt @@ -0,0 +1,60 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import androidx.paging.PagingData +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import timber.log.Timber + +internal class SetTxHistoryCountTransformer( + userWalletId: UserWalletId, + private val transactionsCount: Int, + private val clickIntents: WalletClickIntentsV2, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.SingleCurrency.Content -> prevState.toLoadingState() + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to load transactions history for locked wallet") + prevState + } + is WalletState.MultiCurrency, + -> { + Timber.e("Impossible to load transactions history for multi-currency wallet") + prevState + } + } + } + + private fun WalletState.SingleCurrency.Content.toLoadingState(): WalletState { + return if (txHistoryState is TxHistoryState.Content) { + (txHistoryState as? TxHistoryState.Content)?.contentItems?.update { + Timber.d("Load transactions history: $transactionsCount") + PagingData.from(data = createLoadingItems()) + } + this + } else { + val txHistoryContent = TxHistoryState.Content( + contentItems = MutableStateFlow( + value = PagingData.from(data = createLoadingItems()), + ), + ) + copy(txHistoryState = txHistoryContent) + } + } + + private fun createLoadingItems(): List { + return buildList { + add(TxHistoryState.TxHistoryItemState.Title(onExploreClick = clickIntents::onExploreClick)) + (1..transactionsCount).forEach { + add(TxHistoryState.TxHistoryItemState.Transaction(state = TransactionState.Loading(it.toString()))) + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryItemsErrorTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryItemsErrorTransformer.kt new file mode 100644 index 0000000000..37e26fa534 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryItemsErrorTransformer.kt @@ -0,0 +1,41 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.domain.txhistory.models.TxHistoryListError +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import timber.log.Timber + +internal class SetTxHistoryItemsErrorTransformer( + userWalletId: UserWalletId, + private val error: TxHistoryListError, + private val clickIntents: WalletClickIntentsV2, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.SingleCurrency.Content -> { + prevState.copy( + txHistoryState = when (error) { + is TxHistoryListError.DataError -> { + TxHistoryState.Error( + onReloadClick = clickIntents::onReloadClick, + onExploreClick = clickIntents::onExploreClick, + ) + } + }, + ) + } + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to load transactions history for locked wallet") + prevState + } + is WalletState.MultiCurrency -> { + Timber.e("Impossible to load transactions history for multi-currency wallet") + prevState + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryItemsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryItemsTransformer.kt new file mode 100644 index 0000000000..1bd03fe9b1 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetTxHistoryItemsTransformer.kt @@ -0,0 +1,41 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import androidx.paging.PagingData +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter.TxHistoryItemFlowConverter +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.coroutines.flow.Flow +import timber.log.Timber + +internal class SetTxHistoryItemsTransformer( + private val userWallet: UserWallet, + private val flow: Flow>, + private val clickIntents: WalletClickIntentsV2, +) : WalletStateTransformer(userWallet.walletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.SingleCurrency.Content -> { + val converter = TxHistoryItemFlowConverter( + userWallet = userWallet, + currentState = prevState, + clickIntents = clickIntents, + ) + prevState.copy( + txHistoryState = converter.convert(value = flow), + ) + } + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to load transactions history for locked wallet") + prevState + } + is WalletState.MultiCurrency -> { + Timber.e("Impossible to load transactions history for multi-currency wallet") + prevState + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetWarningsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetWarningsTransformer.kt new file mode 100644 index 0000000000..064c72d1e7 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/SetWarningsTransformer.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletNotification +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import kotlinx.collections.immutable.ImmutableList +import timber.log.Timber + +internal class SetWarningsTransformer( + userWalletId: UserWalletId, + private val warnings: ImmutableList, +) : WalletStateTransformer(userWalletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.MultiCurrency.Content -> prevState.copy(warnings = warnings) + is WalletState.SingleCurrency.Content -> prevState.copy(warnings = warnings) + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to update notifications for locked wallet") + prevState + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UnlockWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UnlockWalletTransformer.kt new file mode 100644 index 0000000000..bd5c0bd61b --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UnlockWalletTransformer.kt @@ -0,0 +1,53 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTopBarConfig +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.utils.WalletLoadingStateFactory +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.toImmutableList +import timber.log.Timber + +internal class UnlockWalletTransformer( + private val unlockedWallets: List, + private val clickIntents: WalletClickIntentsV2, +) : WalletScreenStateTransformer { + + private val walletLoadingStateFactory by lazy { WalletLoadingStateFactory(clickIntents = clickIntents) } + + override fun transform(prevState: WalletScreenState): WalletScreenState { + return prevState.copy( + topBarConfig = prevState.topBarConfig.toUnlockedState(), + wallets = prevState.wallets + .map { state -> + val unlockedWallet = getUnlockedWallet(state.walletCardState.id) + if (unlockedWallet == null) state else createLoadingState(state, unlockedWallet) + } + .toImmutableList(), + ) + } + + private fun WalletTopBarConfig.toUnlockedState(): WalletTopBarConfig { + return copy(onDetailsClick = clickIntents::onDetailsClick) + } + + private fun getUnlockedWallet(walletId: UserWalletId): UserWallet? { + return unlockedWallets.firstOrNull { it.walletId == walletId } + } + + private fun createLoadingState(prevState: WalletState, unlockedWallet: UserWallet): WalletState { + return when (prevState) { + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + -> walletLoadingStateFactory.create(userWallet = unlockedWallet) + is WalletState.MultiCurrency.Content, + is WalletState.SingleCurrency.Content, + -> { + Timber.e("Impossible to unlock wallet with content state") + prevState + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UpdateBalanceHidingModeTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UpdateBalanceHidingModeTransformer.kt new file mode 100644 index 0000000000..6dd7da1e64 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UpdateBalanceHidingModeTransformer.kt @@ -0,0 +1,12 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState + +internal class UpdateBalanceHidingModeTransformer( + private val isHidingMode: Boolean, +) : WalletScreenStateTransformer { + + override fun transform(prevState: WalletScreenState): WalletScreenState { + return prevState.copy(isHidingMode = isHidingMode) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UpdateWalletCardsCountTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UpdateWalletCardsCountTransformer.kt new file mode 100644 index 0000000000..fe89479e52 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/UpdateWalletCardsCountTransformer.kt @@ -0,0 +1,42 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWallet +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.domain.getCardsCount +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import timber.log.Timber + +internal class UpdateWalletCardsCountTransformer( + private val userWallet: UserWallet, +) : WalletStateTransformer(userWallet.walletId) { + + override fun transform(prevState: WalletState): WalletState { + return when (prevState) { + is WalletState.MultiCurrency.Content -> { + prevState.copy(walletCardState = prevState.walletCardState.toUpdatedState()) + } + is WalletState.SingleCurrency.Content -> { + prevState.copy(walletCardState = prevState.walletCardState.toUpdatedState()) + } + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + -> { + Timber.e("Impossible to update wallet cards count for locked wallet") + prevState + } + } + } + + private fun WalletCardState.toUpdatedState(): WalletCardState { + return when (this) { + is WalletCardState.Content -> copy( + additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = userWallet), + imageResId = WalletImageResolver.resolve(userWallet = userWallet), + cardCount = userWallet.getCardsCount(), + ) + else -> this + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/WalletScreenStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/WalletScreenStateTransformer.kt new file mode 100644 index 0000000000..05cbc33f27 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/WalletScreenStateTransformer.kt @@ -0,0 +1,8 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState + +internal interface WalletScreenStateTransformer { + + fun transform(prevState: WalletScreenState): WalletScreenState +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/WalletStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/WalletStateTransformer.kt new file mode 100644 index 0000000000..38af9d7f93 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/WalletStateTransformer.kt @@ -0,0 +1,23 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import kotlinx.collections.immutable.toImmutableList + +internal abstract class WalletStateTransformer( + protected val userWalletId: UserWalletId, +) : WalletScreenStateTransformer { + + abstract fun transform(prevState: WalletState): WalletState + + override fun transform(prevState: WalletScreenState): WalletScreenState { + return prevState.copy( + wallets = prevState.wallets + .map { state -> + if (state.walletCardState.id == userWalletId) transform(state) else state + } + .toImmutableList(), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCardStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCardStateConverter.kt new file mode 100644 index 0000000000..8c3b84bf05 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCardStateConverter.kt @@ -0,0 +1,64 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter + +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.TokenList +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory +import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState +import com.tangem.utils.converter.Converter + +internal class MultiWalletCardStateConverter( + private val fiatBalance: TokenList.FiatBalance, + private val selectedWallet: UserWallet, + private val appCurrency: AppCurrency, +) : Converter { + + override fun convert(value: WalletCardState): WalletCardState { + return when (fiatBalance) { + is TokenList.FiatBalance.Loading -> value.toLoadingState() + is TokenList.FiatBalance.Failed -> value.toErrorState() + is TokenList.FiatBalance.Loaded -> value.toWalletCardState(fiatBalance) + } + } + + private fun WalletCardState.toLoadingState(): WalletCardState { + return WalletCardState.Loading( + id = id, + title = title, + additionalInfo = additionalInfo, + imageResId = imageResId, + onRenameClick = onRenameClick, + onDeleteClick = onDeleteClick, + ) + } + + private fun WalletCardState.toErrorState(): WalletCardState { + return WalletCardState.Error( + id = id, + title = title, + additionalInfo = additionalInfo, + imageResId = imageResId, + onDeleteClick = onDeleteClick, + onRenameClick = onRenameClick, + ) + } + + private fun WalletCardState.toWalletCardState(fiatBalance: TokenList.FiatBalance.Loaded): WalletCardState { + return WalletCardState.Content( + id = id, + title = title, + additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = selectedWallet), + imageResId = imageResId, + onRenameClick = onRenameClick, + onDeleteClick = onDeleteClick, + balance = BigDecimalFormatter.formatFiatAmount( + fiatAmount = fiatBalance.amount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ), + cardCount = selectedWallet.getCardsCount(), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCurrencyActionsConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCurrencyActionsConverter.kt new file mode 100644 index 0000000000..270fa05c9c --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/MultiWalletCurrencyActionsConverter.kt @@ -0,0 +1,96 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.wallet.state.TokenActionButtonConfig +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletCurrencyActionsClickIntentsImplementor +import com.tangem.utils.converter.Converter +import com.tangem.utils.isNullOrZero +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal class MultiWalletCurrencyActionsConverter( + private val userWallet: UserWallet, + private val clickIntents: WalletCurrencyActionsClickIntentsImplementor, +) : Converter> { + + override fun convert(value: TokenActionsState): ImmutableList { + return value.states + .filterIfSingleWithToken() + .mapNotNull { + mapTokenActionState(actionsState = it, cryptoCurrencyStatus = value.cryptoCurrencyStatus) + } + .toImmutableList() + } + + private fun List.filterIfSingleWithToken(): List { + return if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) { + filter { it !is TokenActionsState.ActionState.HideToken } + } else { + this + } + } + + private fun mapTokenActionState( + actionsState: TokenActionsState.ActionState, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): TokenActionButtonConfig? { + if (actionsState is TokenActionsState.ActionState.Send && cryptoCurrencyStatus.value.amount.isNullOrZero()) { + return null + } + + val title: TextReference + val icon: Int + val action: () -> Unit + when (actionsState) { + is TokenActionsState.ActionState.Buy -> { + title = resourceReference(R.string.common_buy) + icon = R.drawable.ic_plus_24 + action = { clickIntents.onBuyClick(cryptoCurrencyStatus) } + } + is TokenActionsState.ActionState.Receive -> { + title = resourceReference(R.string.common_receive) + icon = R.drawable.ic_arrow_down_24 + action = { clickIntents.onReceiveClick(cryptoCurrencyStatus) } + } + is TokenActionsState.ActionState.Sell -> { + title = resourceReference(R.string.common_sell) + icon = R.drawable.ic_currency_24 + action = { clickIntents.onSellClick(cryptoCurrencyStatus) } + } + is TokenActionsState.ActionState.Send -> { + title = resourceReference(R.string.common_send) + icon = R.drawable.ic_arrow_up_24 + action = { clickIntents.onSendClick(cryptoCurrencyStatus) } + } + is TokenActionsState.ActionState.Swap -> { + title = resourceReference(R.string.common_swap) + icon = R.drawable.ic_exchange_horizontal_24 + action = { clickIntents.onSwapClick(cryptoCurrencyStatus) } + } + is TokenActionsState.ActionState.CopyAddress -> { + title = resourceReference(R.string.common_copy_address) + icon = R.drawable.ic_copy_24 + action = { clickIntents.onCopyAddressClick(cryptoCurrencyStatus) } + } + is TokenActionsState.ActionState.HideToken -> { + title = resourceReference(R.string.token_details_hide_token) + icon = R.drawable.ic_hide_24 + action = { clickIntents.onHideTokensClick(cryptoCurrencyStatus) } + } + } + + return TokenActionButtonConfig( + text = title, + iconResId = icon, + onClick = action, + isWarning = actionsState is TokenActionsState.ActionState.HideToken, + enabled = actionsState.enabled, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/SingleWalletCardStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/SingleWalletCardStateConverter.kt new file mode 100644 index 0000000000..e54cf09dfa --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/SingleWalletCardStateConverter.kt @@ -0,0 +1,78 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter + +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory +import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState +import com.tangem.utils.converter.Converter + +internal class SingleWalletCardStateConverter( + private val status: CryptoCurrencyStatus.Status, + private val selectedWallet: UserWallet, + private val appCurrency: AppCurrency, +) : Converter { + + override fun convert(value: WalletCardState): WalletCardState { + return when (status) { + is CryptoCurrencyStatus.Loading -> value.toLoadingState() + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Unreachable, + -> value.toErrorState() + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.NoAccount, + is CryptoCurrencyStatus.NoAmount, + -> value.toContentState(status) + } + } + + private fun WalletCardState.toLoadingState(): WalletCardState { + return WalletCardState.Loading( + id = id, + title = title, + imageResId = imageResId, + onRenameClick = onRenameClick, + onDeleteClick = onDeleteClick, + ) + } + + private fun WalletCardState.toErrorState(): WalletCardState { + return WalletCardState.Error( + id = id, + title = title, + imageResId = imageResId, + onRenameClick = onRenameClick, + onDeleteClick = onDeleteClick, + ) + } + + private fun WalletCardState.toContentState(status: CryptoCurrencyStatus.Status): WalletCardState { + return WalletCardState.Content( + id = id, + title = title, + additionalInfo = WalletAdditionalInfoFactory.resolve( + wallet = selectedWallet, + currencyAmount = status.amount, + ), + imageResId = imageResId, + onRenameClick = onRenameClick, + onDeleteClick = onDeleteClick, + balance = formatFiatAmount(status = status, appCurrency = appCurrency), + cardCount = selectedWallet.getCardsCount(), + ) + } + + private fun formatFiatAmount(status: CryptoCurrencyStatus.Status, appCurrency: AppCurrency): String { + val fiatAmount = status.fiatAmount ?: return BigDecimalFormatter.EMPTY_BALANCE_SIGN + + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = fiatAmount, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/SingleWalletMarketPriceConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/SingleWalletMarketPriceConverter.kt new file mode 100644 index 0000000000..1f1237f976 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/SingleWalletMarketPriceConverter.kt @@ -0,0 +1,68 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter + +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +internal class SingleWalletMarketPriceConverter( + private val status: CryptoCurrencyStatus.Status, + private val appCurrency: AppCurrency, +) : Converter { + + override fun convert(value: MarketPriceBlockState): MarketPriceBlockState { + return when (status) { + CryptoCurrencyStatus.Loading -> MarketPriceBlockState.Loading(value.currencySymbol) + is CryptoCurrencyStatus.NoAccount -> value.toNoAccountState() + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.NoAmount, + -> value.toContentState() + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.Unreachable, + -> MarketPriceBlockState.Error(value.currencySymbol) + } + } + + private fun MarketPriceBlockState.toNoAccountState(): MarketPriceBlockState { + return if (status.fiatRate == null) MarketPriceBlockState.Error(currencySymbol) else toContentState() + } + + private fun MarketPriceBlockState.toContentState(): MarketPriceBlockState { + return MarketPriceBlockState.Content( + currencySymbol = currencySymbol, + price = formatPrice(status = status, appCurrency = appCurrency), + priceChangeConfig = PriceChangeState.Content( + valueInPercent = formatPriceChange(status = status), + type = getPriceChangeType(status = status), + ), + ) + } + + private fun formatPrice(status: CryptoCurrencyStatus.Status, appCurrency: AppCurrency): String { + val fiatRate = status.fiatRate ?: return BigDecimalFormatter.EMPTY_BALANCE_SIGN + + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = fiatRate, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + + private fun formatPriceChange(status: CryptoCurrencyStatus.Status): String { + val priceChange = status.priceChange ?: return BigDecimalFormatter.EMPTY_BALANCE_SIGN + + return BigDecimalFormatter.formatPercent(percent = priceChange, useAbsoluteValue = true) + } + + private fun getPriceChangeType(status: CryptoCurrencyStatus.Status): PriceChangeType { + val priceChange = status.priceChange ?: return PriceChangeType.DOWN + + return if (priceChange > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TokenItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TokenItemStateConverter.kt new file mode 100644 index 0000000000..eef6aea5c9 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TokenItemStateConverter.kt @@ -0,0 +1,122 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter + +import com.tangem.common.Provider +import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.feature.wallet.presentation.common.state.TokenItemState +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +internal class TokenItemStateConverter( + private val appCurrencyProvider: Provider, + private val clickIntents: WalletClickIntentsV2, +) : Converter { + + private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) + + override fun convert(value: CryptoCurrencyStatus): TokenItemState { + return when (value.value) { + is CryptoCurrencyStatus.Loading -> value.mapToLoadingState() + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> value.mapToTokenItemState() + is CryptoCurrencyStatus.MissedDerivation -> value.mapToNoAddressTokenItemState() + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> value.mapToUnreachableTokenItemState() + } + } + + private fun CryptoCurrencyStatus.mapToLoadingState(): TokenItemState.Loading { + return TokenItemState.Loading( + id = currency.id.value, + iconState = iconStateConverter.convert(value = this), + titleState = TokenItemState.TitleState.Content(text = currency.name), + ) + } + + private fun CryptoCurrencyStatus.mapToTokenItemState(): TokenItemState.Content { + return TokenItemState.Content( + id = currency.id.value, + iconState = iconStateConverter.convert(value = this), + titleState = TokenItemState.TitleState.Content( + text = currency.name, + hasPending = value.hasCurrentNetworkTransactions, + ), + fiatAmountState = TokenItemState.FiatAmountState.Content( + text = getFormattedFiatAmount(), + ), + cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = getFormattedAmount()), + cryptoPriceState = getCryptoPriceState(), + onItemClick = { clickIntents.onTokenItemClick(currency) }, + onItemLongClick = { clickIntents.onTokenItemLongClick(cryptoCurrencyStatus = this) }, + ) + } + + private fun CryptoCurrencyStatus.getFormattedAmount(): String { + val amount = value.amount ?: return TokenItemState.UNKNOWN_AMOUNT_SIGN + + return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals) + } + + private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String { + val fiatAmount = value.fiatAmount ?: return TokenItemState.UNKNOWN_AMOUNT_SIGN + val appCurrency = appCurrencyProvider() + + return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol) + } + + private fun CryptoCurrencyStatus.mapToUnreachableTokenItemState() = TokenItemState.Unreachable( + id = currency.id.value, + iconState = iconStateConverter.convert(value = this), + titleState = TokenItemState.TitleState.Content(text = currency.name), + onItemClick = { clickIntents.onTokenItemClick(currency) }, + onItemLongClick = { clickIntents.onTokenItemLongClick(cryptoCurrencyStatus = this) }, + ) + + private fun CryptoCurrencyStatus.mapToNoAddressTokenItemState() = TokenItemState.NoAddress( + id = currency.id.value, + iconState = iconStateConverter.convert(this), + titleState = TokenItemState.TitleState.Content(text = currency.name), + onItemLongClick = { clickIntents.onTokenItemLongClick(cryptoCurrencyStatus = this) }, + ) + + private fun CryptoCurrencyStatus.getCryptoPriceState(): TokenItemState.CryptoPriceState { + val fiatRate = value.fiatRate + val priceChange = value.priceChange + + return if (fiatRate != null && priceChange != null) { + TokenItemState.CryptoPriceState.Content( + price = fiatRate.getFormattedCryptoPrice(), + priceChangePercent = BigDecimalFormatter.formatPercent( + percent = priceChange, + useAbsoluteValue = true, + maxFractionDigits = 1, + minFractionDigits = 1, + ), + type = priceChange.getPriceChangeType(), + ) + } else { + TokenItemState.CryptoPriceState.Unknown + } + } + + private fun BigDecimal.getFormattedCryptoPrice(): String { + val appCurrency = appCurrencyProvider() + return BigDecimalFormatter.formatFiatAmount( + fiatAmount = this, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + } + + private fun BigDecimal.getPriceChangeType(): PriceChangeType { + return if (this > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TokenListStateConverter.kt new file mode 100644 index 0000000000..b9b2ab5cf3 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TokenListStateConverter.kt @@ -0,0 +1,93 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter + +import com.tangem.common.Provider +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.NetworkGroup +import com.tangem.domain.tokens.model.TokenList +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState.TokensListItemState +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.persistentListOf +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState.OrganizeTokensButtonConfig as WalletOrganizeTokensButtonConfig + +internal class TokenListStateConverter( + private val tokenList: TokenList, + private val selectedWallet: UserWallet, + private val appCurrency: AppCurrency, + private val clickIntents: WalletClickIntentsV2, +) : Converter { + + private val tokenStatusConverter = TokenItemStateConverter( + appCurrencyProvider = Provider { appCurrency }, + clickIntents = clickIntents, + ) + + override fun convert(value: WalletTokensListState): WalletTokensListState { + return when (tokenList) { + is TokenList.Empty -> WalletTokensListState.Empty + is TokenList.GroupedByNetwork -> WalletTokensListState.ContentState.Content( + items = tokenList.toGroupedItems(), + organizeTokensButtonConfig = getOrganizeTokensButtonState( + currenciesSize = tokenList.groups.flatMap(NetworkGroup::currencies).size, + ), + ) + is TokenList.Ungrouped -> WalletTokensListState.ContentState.Content( + items = tokenList.toUngroupedItems(), + organizeTokensButtonConfig = getOrganizeTokensButtonState(currenciesSize = tokenList.currencies.size), + ) + } + } + + private fun TokenList.GroupedByNetwork.toGroupedItems(): PersistentList { + return groups.fold(initial = persistentListOf()) { acc, group -> + acc.mutate { it.addGroup(group) } + } + } + + private fun TokenList.Ungrouped.toUngroupedItems(): PersistentList { + return currencies.fold(initial = persistentListOf()) { acc, token -> + acc.mutate { it.addToken(token) } + } + } + + private fun MutableList.addGroup(group: NetworkGroup): List { + val groupTitle = TokensListItemState.NetworkGroupTitle( + id = group.network.hashCode(), + name = stringReference(group.network.name), + ) + + add(groupTitle) + group.currencies.forEach { token -> addToken(token) } + + return this + } + + private fun MutableList.addToken(token: CryptoCurrencyStatus): List { + val tokenItemState = tokenStatusConverter.convert(token) + add(TokensListItemState.Token(tokenItemState)) + + return this + } + + private fun getOrganizeTokensButtonState(currenciesSize: Int): WalletOrganizeTokensButtonConfig? { + return if (currenciesSize > 1 && !isSingleCurrencyWalletWithToken()) { + WalletOrganizeTokensButtonConfig( + isEnabled = tokenList.totalFiatBalance !is TokenList.FiatBalance.Loading, + onClick = clickIntents::onOrganizeTokensClick, + ) + } else { + null + } + } + + private fun isSingleCurrencyWalletWithToken(): Boolean { + return selectedWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TxHistoryItemFlowConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TxHistoryItemFlowConverter.kt new file mode 100644 index 0000000000..6aeb577d67 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TxHistoryItemFlowConverter.kt @@ -0,0 +1,118 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter + +import androidx.paging.* +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.core.ui.components.transactions.state.TxHistoryState.TxHistoryItemState +import com.tangem.core.ui.utils.toDateFormat +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import com.tangem.utils.converter.Converter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import java.util.UUID + +private val scope = CoroutineScope(Dispatchers.IO) + +internal class TxHistoryItemFlowConverter( + private val userWallet: UserWallet, + private val currentState: WalletState.SingleCurrency.Content, + private val clickIntents: WalletClickIntentsV2, +) : Converter>, TxHistoryState?> { + + private val txHistoryItemConverter by lazy { + val blockchain = userWallet.scanResponse.cardTypesResolver.getBlockchain() + TxHistoryItemStateConverter( + symbol = blockchain.currency, + decimals = blockchain.decimals(), + clickIntents = clickIntents, + ) + } + + override fun convert(value: Flow>): TxHistoryState { + val txHistoryContent = currentState.txHistoryState as? TxHistoryState.Content + ?: TxHistoryState.Content(contentItems = MutableStateFlow(PagingData.empty())) + + // FIXME: TxHistoryRepository should send loading transactions + // [REDACTED_JIRA] + value + .onEach { txHistoryStatePagingData -> + txHistoryContent.contentItems.update { + txHistoryStatePagingData + .map { item -> + // [createTransactionState] returns timestamp without formatting + TxHistoryItemState.Transaction(state = createTransactionState(item)) + } + .insertHeaderItem( + terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE, + item = TxHistoryItemState.Title(clickIntents::onExploreClick), + ) + .insertGroupTitle() // method uses the raw timestamp + .formatTransactionsTimestamp() // method formats the timestamp + } + } + .cachedIn(scope) + .launchIn(scope) + + return txHistoryContent + } + + private fun createTransactionState(item: TxHistoryItem): TransactionState { + return txHistoryItemConverter.convert(value = item) + } + + private fun PagingData.insertGroupTitle(): PagingData { + return insertSeparators(terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE) { before, after -> + // Use raw timestamp to get date + + // If [afterDate] is the first transaction in the flow, add the group title + val afterDate = after.getTimestamp()?.toDateFormat() ?: return@insertSeparators null + if (before is TxHistoryItemState.Title) { + return@insertSeparators TxHistoryItemState.GroupTitle(afterDate, itemKey = UUID.randomUUID().toString()) + } + + /* + * If [beforeDate] is not equals to [afterDate], then [afterDate] is first transaction in + * the new group + */ + val beforeDate = before.getTimestamp()?.toDateFormat() ?: return@insertSeparators null + return@insertSeparators if (beforeDate != afterDate) { + TxHistoryItemState.GroupTitle(afterDate, itemKey = UUID.randomUUID().toString()) + } else { + null + } + } + } + + /** + * Map the [PagingData] to format the [TxHistoryItemState] timestamp + */ + private fun PagingData.formatTransactionsTimestamp(): PagingData { + return map { txHistoryItemState -> + if (txHistoryItemState is TxHistoryItemState.Transaction && + txHistoryItemState.state is TransactionState.Content + ) { + val txContent = txHistoryItemState.state as TransactionState.Content + txHistoryItemState.copy( + state = txContent.copy(timestamp = txContent.timestamp.toLong().toTimeFormat()), + ) + } else { + txHistoryItemState + } + } + } + + private fun TxHistoryItemState?.getTimestamp(): Long? { + return if (this is TxHistoryItemState.Transaction && this.state is TransactionState.Content) { + val txContent = this.state as TransactionState.Content + requireNotNull(txContent.timestamp.toLongOrNull()) { "Timestamp must be Long type" } + } else { + null + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TxHistoryItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TxHistoryItemStateConverter.kt new file mode 100644 index 0000000000..e9c78803f3 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/transformers/converter/TxHistoryItemStateConverter.kt @@ -0,0 +1,109 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter + +import com.tangem.core.ui.components.transactions.state.TransactionState +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.domain.txhistory.models.TxHistoryItem +import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import com.tangem.utils.converter.Converter +import com.tangem.utils.toBriefAddressFormat +import com.tangem.utils.toFormattedCurrencyString + +internal class TxHistoryItemStateConverter( + private val symbol: String, + private val decimals: Int, + private val clickIntents: WalletClickIntentsV2, +) : Converter { + + override fun convert(value: TxHistoryItem): TransactionState { + return createTransactionStateItem(item = value) + } + + @Suppress("LongMethod") + private fun createTransactionStateItem(item: TxHistoryItem): TransactionState { + return TransactionState.Content( + txHash = item.txHash, + amount = item.getAmount(), + timestamp = item.getRawTimestamp(), + status = item.status.tiUiStatus(), + direction = item.extractDirection(), + iconRes = item.extractIcon(), + title = item.extractTitle(), + subtitle = item.extractSubtitle(), + onClick = { clickIntents.onTransactionClick(item.txHash) }, + ) + } + + private fun TxHistoryItem.extractIcon(): Int = if (status == TxHistoryItem.TransactionStatus.Failed) { + R.drawable.ic_close_24 + } else { + when (type) { + is TxHistoryItem.TransactionType.Approve -> R.drawable.ic_doc_24 + is TxHistoryItem.TransactionType.Operation, + is TxHistoryItem.TransactionType.Swap, + is TxHistoryItem.TransactionType.Transfer, + is TxHistoryItem.TransactionType.UnknownOperation, + -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 + } + } + + private fun TxHistoryItem.extractTitle(): TextReference = when (val type = type) { + is TxHistoryItem.TransactionType.Approve -> resourceReference(R.string.common_approval) + is TxHistoryItem.TransactionType.Operation -> stringReference(type.name) + is TxHistoryItem.TransactionType.Swap -> resourceReference(R.string.common_swap) + is TxHistoryItem.TransactionType.Transfer -> resourceReference(R.string.common_transfer) + is TxHistoryItem.TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + } + + private fun TxHistoryItem.extractSubtitle(): TextReference = + when (val interactionAddress = interactionAddressType) { + is TxHistoryItem.InteractionAddressType.Contract -> resourceReference( + id = R.string.transaction_history_contract_address, + formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), + ) + is TxHistoryItem.InteractionAddressType.Multiple -> resourceReference( + id = if (isOutgoing) { + R.string.transaction_history_transaction_to_address + } else { + R.string.transaction_history_transaction_from_address + }, + formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)), + ) + is TxHistoryItem.InteractionAddressType.User -> resourceReference( + id = if (isOutgoing) { + R.string.transaction_history_transaction_to_address + } else { + R.string.transaction_history_transaction_from_address + }, + formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()), + ) + } + + private fun TxHistoryItem.extractDirection() = + if (isOutgoing) TransactionState.Content.Direction.OUTGOING else TransactionState.Content.Direction.INCOMING + + /** + * Get timestamp without formatting. + * It's life hack that help us to add transaction's group title to flow. + * + * @see [convert] + */ + private fun TxHistoryItem.getRawTimestamp() = this.timestampInMillis.toString() + + private fun TxHistoryItem.TransactionStatus.tiUiStatus() = when (this) { + TxHistoryItem.TransactionStatus.Confirmed -> TransactionState.Content.Status.Confirmed + TxHistoryItem.TransactionStatus.Failed -> TransactionState.Content.Status.Failed + TxHistoryItem.TransactionStatus.Unconfirmed -> TransactionState.Content.Status.Unconfirmed + } + + private fun TxHistoryItem.getAmount(): String { + val prefix = when (status) { + TxHistoryItem.TransactionStatus.Failed -> "" + else -> if (isOutgoing) "-" else "+" + } + return prefix + amount.toFormattedCurrencyString(currency = symbol, decimals = decimals) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/UserWalletConverterExt.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/UserWalletConverterExt.kt new file mode 100644 index 0000000000..75d10c7b80 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/UserWalletConverterExt.kt @@ -0,0 +1,16 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.utils + +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState + +internal inline fun UserWallet.createStateByWalletType( + multiCurrencyCreator: () -> WalletState.MultiCurrency, + singleCurrencyCreator: () -> WalletState.SingleCurrency, +): WalletState { + return if (isWalletWithTokens()) multiCurrencyCreator() else singleCurrencyCreator() +} + +private fun UserWallet.isWalletWithTokens(): Boolean { + return isMultiCurrency || scanResponse.cardTypesResolver.isSingleWalletWithToken() +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/WalletEventSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/WalletEventSender.kt new file mode 100644 index 0000000000..6cc3953821 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/WalletEventSender.kt @@ -0,0 +1,29 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.utils + +import com.tangem.core.ui.event.consumedEvent +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SendEventTransformer +import javax.inject.Inject + +/** + * Component for sending events [WalletEvent] on WalletScreen + * + * @property stateHolder state holder for changing state + * +[REDACTED_AUTHOR] + */ +internal class WalletEventSender @Inject constructor( + private val stateHolder: WalletStateHolderV2, +) { + + fun send(event: WalletEvent) { + stateHolder.update(transformer = SendEventTransformer(event = event, onConsume = ::onConsume)) + } + + private fun onConsume() { + stateHolder.update { + it.copy(event = consumedEvent()) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/WalletLoadingStateFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/WalletLoadingStateFactory.kt new file mode 100644 index 0000000000..055d23befa --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state2/utils/WalletLoadingStateFactory.kt @@ -0,0 +1,85 @@ +package com.tangem.feature.wallet.presentation.wallet.state2.utils + +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.wallets.models.UserWallet +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.components.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletManageButton +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletPullToRefreshConfig +import com.tangem.feature.wallet.presentation.wallet.state2.ManageTokensButtonConfig +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Factory for creating loading state [WalletState] + * + * @property clickIntents click intents + */ +internal class WalletLoadingStateFactory(private val clickIntents: WalletClickIntentsV2) { + + fun create(userWallet: UserWallet): WalletState { + return userWallet.createStateByWalletType( + multiCurrencyCreator = { createLoadingMultiCurrencyContent(userWallet) }, + singleCurrencyCreator = { createLoadingSingleCurrencyContent(userWallet) }, + ) + } + + private fun createLoadingMultiCurrencyContent(userWallet: UserWallet): WalletState.MultiCurrency.Content { + return WalletState.MultiCurrency.Content( + pullToRefreshConfig = createPullToRefreshConfig(), + walletCardState = userWallet.toLoadingWalletCardState(), + warnings = persistentListOf(), + bottomSheetConfig = null, + tokensListState = WalletTokensListState.ContentState.Loading, + manageTokensButtonConfig = ManageTokensButtonConfig(clickIntents::onManageTokensClick), + ) + } + + private fun createLoadingSingleCurrencyContent(userWallet: UserWallet): WalletState.SingleCurrency.Content { + val currencySymbol = userWallet.scanResponse.cardTypesResolver.getBlockchain().currency + return WalletState.SingleCurrency.Content( + pullToRefreshConfig = createPullToRefreshConfig(), + walletCardState = userWallet.toLoadingWalletCardState(), + warnings = persistentListOf(), + bottomSheetConfig = null, + buttons = createDisabledButtons(), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = currencySymbol), + txHistoryState = TxHistoryState.Content( + contentItems = MutableStateFlow( + value = TxHistoryState.getDefaultLoadingTransactions(clickIntents::onExploreClick), + ), + ), + ) + } + + private fun createPullToRefreshConfig(): WalletPullToRefreshConfig { + return WalletPullToRefreshConfig(onRefresh = clickIntents::onRefreshSwipe, isRefreshing = false) + } + + private fun UserWallet.toLoadingWalletCardState(): WalletCardState { + return WalletCardState.Loading( + id = walletId, + title = name, + additionalInfo = if (isMultiCurrency) WalletAdditionalInfoFactory.resolve(wallet = this) else null, + imageResId = WalletImageResolver.resolve(userWallet = this), + onRenameClick = clickIntents::onRenameClick, + onDeleteClick = clickIntents::onDeleteBeforeConfirmationClick, + ) + } + + private fun createDisabledButtons(): PersistentList { + return persistentListOf( + WalletManageButton.Buy(enabled = false, onClick = {}), + WalletManageButton.Send(enabled = false, onClick = {}), + WalletManageButton.Receive(enabled = false, onClick = {}), + WalletManageButton.Sell(enabled = false, onClick = {}), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt new file mode 100644 index 0000000000..08b89be0c7 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/MultiWalletWarningsSubscriber.kt @@ -0,0 +1,37 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.domain.GetMultiWalletWarningsFactory +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletNotification +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetWarningsTransformer +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +import kotlin.coroutines.CoroutineContext + +internal class MultiWalletWarningsSubscriber( + private val userWalletId: UserWalletId, + private val stateHolder: WalletStateHolderV2, + private val clickIntents: WalletClickIntentsV2, + private val getMultiWalletWarningsFactory: GetMultiWalletWarningsFactory, +) : WalletSubscriber>(name = "multi_wallet_warnings") { + + override fun create( + coroutineScope: CoroutineScope, + uiDispatcher: CoroutineContext, + ): Flow> { + return getMultiWalletWarningsFactory.create(clickIntents) + .conflate() + .distinctUntilChanged() + .onEach { + stateHolder.update( + SetWarningsTransformer(userWalletId = userWalletId, warnings = it), + ) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/PrimaryCurrencySubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/PrimaryCurrencySubscriber.kt new file mode 100644 index 0000000000..fde79c3d02 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/PrimaryCurrencySubscriber.kt @@ -0,0 +1,92 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import arrow.core.Either +import com.tangem.common.extensions.isZero +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.settings.SetWalletWithFundsFoundUseCase +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.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetPrimaryCurrencyTransformer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +import java.math.BigDecimal +import kotlin.coroutines.CoroutineContext + +internal class PrimaryCurrencySubscriber( + private val userWallet: UserWallet, + private val appCurrency: AppCurrency, + private val stateHolder: WalletStateHolderV2, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val setWalletWithFundsFoundUseCase: SetWalletWithFundsFoundUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, +) : WalletSubscriber>(name = "primary_currency") { + + override fun create( + coroutineScope: CoroutineScope, + uiDispatcher: CoroutineContext, + ): Flow> { + return getPrimaryCurrencyStatusUpdatesUseCase(userWallet.walletId) + .conflate() + .distinctUntilChanged() + .onEach(::updateContent) + .onEach(::sendAnalyticsEvent) + .onEach(::checkWalletWithFunds) + } + + private fun updateContent(maybeCurrencyStatus: Either) { + val status = (maybeCurrencyStatus as? Either.Right)?.value ?: return + + stateHolder.update( + SetPrimaryCurrencyTransformer( + status = status.value, + userWallet = userWallet, + appCurrency = appCurrency, + ), + ) + } + + private fun sendAnalyticsEvent(maybeCurrencyStatus: Either) { + val status = (maybeCurrencyStatus as? Either.Right)?.value ?: return + + val fiatAmount = status.value.fiatAmount + val cardBalanceState = when (status.value) { + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.NoAccount, + is CryptoCurrencyStatus.NoAmount, + -> createCardBalanceState(fiatAmount) + is CryptoCurrencyStatus.NoQuote -> AnalyticsParam.CardBalanceState.NoRate + is CryptoCurrencyStatus.Unreachable -> AnalyticsParam.CardBalanceState.BlockchainError + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.Custom, + -> null + } + + cardBalanceState?.let { + analyticsEventHandler.send(event = WalletScreenAnalyticsEvent.Basic.BalanceLoaded(balance = it)) + } + } + + private fun createCardBalanceState(fiatAmount: BigDecimal?): AnalyticsParam.CardBalanceState? { + return when { + fiatAmount == null -> null + fiatAmount.isZero() -> AnalyticsParam.CardBalanceState.Empty + else -> AnalyticsParam.CardBalanceState.Full + } + } + + private suspend fun checkWalletWithFunds(maybeCurrencyStatus: Either) { + val status = (maybeCurrencyStatus as? Either.Right)?.value ?: return + + if (status.value.amount?.isZero() == false) setWalletWithFundsFoundUseCase() + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletButtonsSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletButtonsSubscriber.kt new file mode 100644 index 0000000000..1f0de740d0 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletButtonsSubscriber.kt @@ -0,0 +1,45 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.model.TokenActionsState +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.domain.collectLatest +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetCryptoCurrencyActionsTransformer +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlin.coroutines.CoroutineContext + +internal class SingleWalletButtonsSubscriber( + private val userWallet: UserWallet, + private val stateHolder: WalletStateHolderV2, + private val clickIntents: WalletClickIntentsV2, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, +) : WalletSubscriber(name = "single_wallet_buttons") { + + override fun create(coroutineScope: CoroutineScope, uiDispatcher: CoroutineContext): Flow { + return channelFlow { + getPrimaryCurrencyStatusUpdatesUseCase.collectLatest(userWalletId = userWallet.walletId) { status -> + getCryptoCurrencyActionsUseCase(userWallet = userWallet, cryptoCurrencyStatus = status) + .conflate() + .distinctUntilChanged() + .firstOrNull() + ?.let { send(it) } + } + } + .onEach(::updateContent) + } + + private fun updateContent(tokenActionsState: TokenActionsState) { + stateHolder.update( + SetCryptoCurrencyActionsTransformer( + tokenActionsState = tokenActionsState, + userWallet = userWallet, + clickIntents = clickIntents, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletNotificationsSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletNotificationsSubscriber.kt new file mode 100644 index 0000000000..64df0d025e --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletNotificationsSubscriber.kt @@ -0,0 +1,40 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.domain.GetSingleWalletWarningsFactory +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletNotification +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetWarningsTransformer +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +import kotlin.coroutines.CoroutineContext + +/** +[REDACTED_AUTHOR] + */ +internal class SingleWalletNotificationsSubscriber( + private val userWalletId: UserWalletId, + private val stateHolder: WalletStateHolderV2, + private val getSingleWalletWarningsFactory: GetSingleWalletWarningsFactory, + private val clickIntents: WalletClickIntentsV2, +) : WalletSubscriber>(name = "single_wallet_warnings") { + + override fun create( + coroutineScope: CoroutineScope, + uiDispatcher: CoroutineContext, + ): Flow> { + return getSingleWalletWarningsFactory.create(clickIntents) + .conflate() + .distinctUntilChanged() + .onEach { + stateHolder.update( + SetWarningsTransformer(userWalletId = userWalletId, warnings = it), + ) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenListSubscriber.kt new file mode 100644 index 0000000000..64072bdc87 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenListSubscriber.kt @@ -0,0 +1,60 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.GetCardTokensListUseCase +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.TokenList +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender +import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTokenListErrorTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTokenListTransformer +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +import kotlin.coroutines.CoroutineContext + +@Suppress("LongParameterList") +internal class SingleWalletWithTokenListSubscriber( + private val userWallet: UserWallet, + private val appCurrency: AppCurrency, + private val stateHolder: WalletStateHolderV2, + private val clickIntents: WalletClickIntentsV2, + private val tokenListAnalyticsSender: TokenListAnalyticsSender, + private val walletWithFundsChecker: WalletWithFundsChecker, + private val getCardTokensListUseCase: GetCardTokensListUseCase, +) : WalletSubscriber>(name = "single_wallet_with_token_list") { + + override fun create( + coroutineScope: CoroutineScope, + uiDispatcher: CoroutineContext, + ): Flow> { + return getCardTokensListUseCase(userWalletId = userWallet.walletId) + .conflate() + .distinctUntilChanged() + .onEach(::updateContent) + .onEach { tokenListAnalyticsSender.send(userWallet, maybeTokenList = it) } + .onEach(walletWithFundsChecker::check) + } + + private fun updateContent(maybeTokenList: Either) { + stateHolder.update( + maybeTokenList.fold( + ifLeft = { SetTokenListErrorTransformer(userWalletId = userWallet.walletId, error = it) }, + ifRight = { + SetTokenListTransformer( + tokenList = it, + userWallet = userWallet, + appCurrency = appCurrency, + clickIntents = clickIntents, + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenListSubscriber.kt new file mode 100644 index 0000000000..c8f2a72509 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenListSubscriber.kt @@ -0,0 +1,62 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.tokens.GetTokenListUseCase +import com.tangem.domain.tokens.error.TokenListError +import com.tangem.domain.tokens.model.TokenList +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender +import com.tangem.feature.wallet.presentation.wallet.domain.WalletWithFundsChecker +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTokenListErrorTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTokenListTransformer +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onEach +import kotlin.coroutines.CoroutineContext + +typealias MaybeTokenListFlow = Flow> + +@Suppress("LongParameterList") +internal class TokenListSubscriber( + private val userWallet: UserWallet, + private val appCurrency: AppCurrency, + private val stateHolder: WalletStateHolderV2, + private val clickIntents: WalletClickIntentsV2, + private val tokenListAnalyticsSender: TokenListAnalyticsSender, + private val walletWithFundsChecker: WalletWithFundsChecker, + private val getTokenListUseCase: GetTokenListUseCase, +) : WalletSubscriber>(name = "token_list") { + + override fun create( + coroutineScope: CoroutineScope, + uiDispatcher: CoroutineContext, + ): Flow> { + return getTokenListUseCase(userWalletId = userWallet.walletId) + .conflate() + .distinctUntilChanged() + .onEach(::updateContent) + .onEach { tokenListAnalyticsSender.send(userWallet, maybeTokenList = it) } + .onEach(walletWithFundsChecker::check) + } + + private fun updateContent(maybeTokenList: Either) { + stateHolder.update( + maybeTokenList.fold( + ifLeft = { SetTokenListErrorTransformer(userWalletId = userWallet.walletId, error = it) }, + ifRight = { + SetTokenListTransformer( + tokenList = it, + userWallet = userWallet, + appCurrency = appCurrency, + clickIntents = clickIntents, + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TxHistorySubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TxHistorySubscriber.kt new file mode 100644 index 0000000000..6da06e3029 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TxHistorySubscriber.kt @@ -0,0 +1,108 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import androidx.paging.PagingData +import androidx.paging.cachedIn +import arrow.core.Either +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.txhistory.models.TxHistoryListError +import com.tangem.domain.txhistory.models.TxHistoryStateError +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase +import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.feature.wallet.presentation.wallet.domain.collectLatest +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTxHistoryCountErrorTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTxHistoryCountTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTxHistoryItemsErrorTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTxHistoryItemsTransformer +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.coroutines.CoroutineContext + +typealias MaybeTxHistoryCount = Either +typealias MaybeTxHistoryItems = Either>> + +@Suppress("LongParameterList") +internal class TxHistorySubscriber( + private val userWallet: UserWallet, + private val isRefresh: Boolean, + private val stateHolder: WalletStateHolderV2, + private val clickIntents: WalletClickIntentsV2, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase, +) : WalletSubscriber>(name = "tx_history") { + + override fun create( + coroutineScope: CoroutineScope, + uiDispatcher: CoroutineContext, + ): Flow> { + return flow { + getPrimaryCurrencyStatusUpdatesUseCase.collectLatest(userWalletId = userWallet.walletId) { status -> + val maybeTxHistoryItemCount = txHistoryItemsCountUseCase( + userWalletId = userWallet.walletId, + currency = status.currency, + ) + + setLoadingTxHistoryState(maybeTxHistoryItemCount, status) + + maybeTxHistoryItemCount.onRight { + val maybeTxHistoryItems = txHistoryItemsUseCase( + userWalletId = userWallet.walletId, + currency = status.currency, + refresh = isRefresh, + ).map { it.cachedIn(coroutineScope) } + + setLoadedTxHistoryState(maybeTxHistoryItems) + } + } + } + } + + private fun setLoadingTxHistoryState(maybeTxHistoryItemCount: MaybeTxHistoryCount, status: CryptoCurrencyStatus) { + stateHolder.update( + maybeTxHistoryItemCount.fold( + ifLeft = { + SetTxHistoryCountErrorTransformer( + userWallet = userWallet, + error = it, + pendingTransactions = status.value.pendingTransactions, + clickIntents = clickIntents, + ) + }, + ifRight = { + SetTxHistoryCountTransformer( + userWalletId = userWallet.walletId, + transactionsCount = it, + clickIntents = clickIntents, + ) + }, + ), + ) + } + + private fun setLoadedTxHistoryState(maybeTxHistoryItems: MaybeTxHistoryItems) { + stateHolder.update( + maybeTxHistoryItems.fold( + ifLeft = { + SetTxHistoryItemsErrorTransformer( + userWalletId = userWallet.walletId, + error = it, + clickIntents = clickIntents, + ) + }, + ifRight = { + SetTxHistoryItemsTransformer( + userWallet = userWallet, + flow = it, + clickIntents = clickIntents, + ) + }, + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletSubscriber.kt new file mode 100644 index 0000000000..112c7cbe8a --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/WalletSubscriber.kt @@ -0,0 +1,30 @@ +package com.tangem.feature.wallet.presentation.wallet.subscribers + +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import timber.log.Timber +import kotlin.coroutines.CoroutineContext + +/** + * Component for implementation of flow subscription + * + * @property name unique name of subscriber + * [T] - type of flow + * +[REDACTED_AUTHOR] + */ +internal abstract class WalletSubscriber(val name: String) { + + protected abstract fun create(coroutineScope: CoroutineScope, uiDispatcher: CoroutineContext): Flow + + fun subscribe(coroutineScope: CoroutineScope, dispatchers: CoroutineDispatcherProvider): Job { + Timber.d("Subscribe on $name") + return create(coroutineScope, dispatchers.main) + .flowOn(dispatchers.main) + .launchIn(coroutineScope) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffect.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffect.kt index a6a26d1828..dbb5f28cfa 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffect.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffect.kt @@ -66,6 +66,7 @@ internal fun WalletEventEffect( } .addOnFailureListener(Timber::e) } + is WalletEvent.DemonstrateWalletsScrollPreview -> Unit } }, ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffectV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffectV2.kt new file mode 100644 index 0000000000..73d54f4ddc --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletEventEffectV2.kt @@ -0,0 +1,62 @@ +package com.tangem.feature.wallet.presentation.wallet.ui + +import android.widget.Toast +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import com.tangem.core.ui.event.EventEffect +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.feature.wallet.presentation.wallet.state.WalletAlertState +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.ui.utils.ReviewManagerRequester +import com.tangem.feature.wallet.presentation.wallet.ui.utils.animateScrollByIndex +import com.tangem.feature.wallet.presentation.wallet.ui.utils.demonstrateScrolling + +@Suppress("LongParameterList") +@Composable +internal fun WalletEventEffectV2( + walletsListState: LazyListState, + snackbarHostState: SnackbarHostState, + event: StateEvent, + selectedWalletIndex: Int, + onAutoScrollSet: () -> Unit, + onAlertConfigSet: (WalletAlertState) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val resources = LocalContext.current.resources + val clipboardManager = LocalClipboardManager.current + EventEffect( + event = event, + onTrigger = { value -> + when (value) { + is WalletEvent.ChangeWallet -> { + onAutoScrollSet() + walletsListState.animateScrollByIndex(prevIndex = selectedWalletIndex, newIndex = value.index) + } + is WalletEvent.ShowError -> { + snackbarHostState.showSnackbar(message = value.text.resolveReference(resources)) + } + is WalletEvent.ShowToast -> { + Toast.makeText(context, value.text.resolveReference(resources), Toast.LENGTH_SHORT).show() + } + is WalletEvent.CopyAddress -> { + clipboardManager.setText(AnnotatedString(value.address)) + Toast.makeText(context, value.toast.resolveReference(resources), Toast.LENGTH_SHORT).show() + } + is WalletEvent.ShowAlert -> onAlertConfigSet(value.state) + is WalletEvent.RateApp -> { + ReviewManagerRequester.request(context = context, onDismissClick = value.onDismissClick) + } + is WalletEvent.DemonstrateWalletsScrollPreview -> { + walletsListState.demonstrateScrolling(coroutineScope = coroutineScope, direction = value.direction) + } + } + }, + ) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreenV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreenV2.kt new file mode 100644 index 0000000000..fc946a27f4 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreenV2.kt @@ -0,0 +1,243 @@ +package com.tangem.feature.wallet.presentation.wallet.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.paging.compose.collectAsLazyPagingItems +import com.tangem.core.ui.components.PrimaryButton +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.core.ui.components.transactions.state.TxHistoryState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.wallet.state.ActionsBottomSheetConfig +import com.tangem.feature.wallet.presentation.wallet.state.WalletAlertState +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletBottomSheetConfig +import com.tangem.feature.wallet.presentation.wallet.state2.NOT_INITIALIZED_WALLET_INDEX +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState +import com.tangem.feature.wallet.presentation.wallet.ui.components.TokenActionsBottomSheet +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.multicurrency.organizeTokensButton +import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.controlButtons +import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock +import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun WalletScreenV2(state: WalletScreenState) { + BackHandler(onBack = state.onBackClick) + + // It means that screen is still initializing + if (state.selectedWalletIndex == NOT_INITIALIZED_WALLET_INDEX) return + + val walletsListState = rememberLazyListState(initialFirstVisibleItemIndex = state.selectedWalletIndex) + val snackbarHostState = remember(::SnackbarHostState) + val isAutoScroll = remember { mutableStateOf(value = false) } + + WalletContent( + state = state, + walletsListState = walletsListState, + snackbarHostState = snackbarHostState, + isAutoScroll = isAutoScroll, + onAutoScrollReset = { isAutoScroll.value = false }, + ) + + var alertConfig by remember { mutableStateOf(value = null) } + + alertConfig?.let { + WalletAlert(state = it, onDismiss = { alertConfig = null }) + } + + WalletEventEffectV2( + event = state.event, + selectedWalletIndex = state.selectedWalletIndex, + walletsListState = walletsListState, + snackbarHostState = snackbarHostState, + onAlertConfigSet = { alertConfig = it }, + onAutoScrollSet = { isAutoScroll.value = true }, + ) +} + +@Suppress("LongMethod") +@Composable +private fun WalletContent( + state: WalletScreenState, + walletsListState: LazyListState, + snackbarHostState: SnackbarHostState, + isAutoScroll: State, + onAutoScrollReset: () -> Unit, +) { + var selectedWalletIndex by remember { mutableIntStateOf(state.selectedWalletIndex) } + val selectedWallet = state.wallets[selectedWalletIndex] + + BaseScaffold(state = state, selectedWallet = selectedWallet, snackbarHostState = snackbarHostState) { + val movableItemModifier = Modifier.changeWalletAnimator(walletsListState) + + val lazyTxHistoryItems = (selectedWallet as? WalletState.SingleCurrency)?.let { walletState -> + (walletState.txHistoryState as? TxHistoryState.Content)?.contentItems?.collectAsLazyPagingItems() + } + + val txHistoryItems by remember(selectedWallet.walletCardState.id, lazyTxHistoryItems?.itemCount) { + mutableStateOf(value = lazyTxHistoryItems) + } + + val betweenItemsPadding = TangemTheme.dimens.spacing14 + val horizontalPadding = TangemTheme.dimens.spacing16 + val itemModifier = movableItemModifier + .padding(top = betweenItemsPadding) + .padding(horizontal = horizontalPadding) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = TangemTheme.dimens.spacing8, + bottom = TangemTheme.dimens.spacing92, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item( + key = state.wallets.map { it.walletCardState.id }, + contentType = state.wallets.map { it.walletCardState.id }, + ) { + WalletsList( + lazyListState = walletsListState, + wallets = state.wallets.map(WalletState::walletCardState).toImmutableList(), + isBalanceHidden = state.isHidingMode, + ) + } + + (selectedWallet as? WalletState.SingleCurrency)?.let { + controlButtons( + configs = it.buttons, + selectedWalletIndex = selectedWalletIndex, + modifier = movableItemModifier.padding(top = betweenItemsPadding), + ) + } + + notifications(configs = selectedWallet.warnings, modifier = itemModifier) + + (selectedWallet as? WalletState.SingleCurrency)?.let { walletState -> + walletState.marketPriceBlockState?.let { marketPriceBlockState -> + marketPriceBlock(state = marketPriceBlockState, modifier = itemModifier) + } + } + + contentItemsV2( + state = selectedWallet, + txHistoryItems = txHistoryItems, + isBalanceHidden = state.isHidingMode, + modifier = movableItemModifier, + ) + + organizeTokens(state = selectedWallet, itemModifier = itemModifier) + } + + val bottomSheetConfig = selectedWallet.bottomSheetConfig + if (bottomSheetConfig != null) { + when (bottomSheetConfig.content) { + is WalletBottomSheetConfig -> WalletBottomSheet(config = bottomSheetConfig) + is TokenReceiveBottomSheetConfig -> TokenReceiveBottomSheet(config = bottomSheetConfig) + is ActionsBottomSheetConfig -> TokenActionsBottomSheet(config = bottomSheetConfig) + is ChooseAddressBottomSheetConfig -> ChooseAddressBottomSheet(config = bottomSheetConfig) + } + } + + WalletsListEffectsV2( + lazyListState = walletsListState, + selectedWalletIndex = selectedWalletIndex, + onWalletChange = state.onWalletChange, + onSelectedWalletIndexSet = { selectedWalletIndex = it }, + isAutoScroll = isAutoScroll, + onAutoScrollReset = onAutoScrollReset, + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun BaseScaffold( + state: WalletScreenState, + selectedWallet: WalletState, + snackbarHostState: SnackbarHostState, + content: @Composable () -> Unit, +) { + Scaffold( + topBar = { WalletTopBar(config = state.topBarConfig) }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + floatingActionButton = { + val manageTokensButtonConfig by remember(state.selectedWalletIndex) { + mutableStateOf( + (state.wallets[state.selectedWalletIndex] as? WalletState.MultiCurrency)?.manageTokensButtonConfig, + ) + } + + manageTokensButtonConfig?.let { ManageTokensButton(onClick = it.onClick) } + }, + floatingActionButtonPosition = FabPosition.Center, + containerColor = TangemTheme.colors.background.secondary, + content = { + val pullRefreshState = rememberPullRefreshState( + refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + onRefresh = selectedWallet.pullToRefreshConfig.onRefresh, + ) + + Box( + modifier = Modifier + .pullRefresh(pullRefreshState) + .padding(it), + ) { + content() + + WalletPullToRefreshIndicator( + isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + }, + ) +} + +@Composable +private fun ManageTokensButton(onClick: () -> Unit) { + PrimaryButton( + text = stringResource(id = R.string.main_manage_tokens), + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + ) +} + +internal fun LazyListScope.organizeTokens(state: WalletState, itemModifier: Modifier) { + (state as? WalletState.MultiCurrency)?.let { + (state.tokensListState as? WalletTokensListState.ContentState)?.let { + it.organizeTokensButtonConfig?.let { config -> + organizeTokensButton( + modifier = itemModifier, + isEnabled = config.isEnabled, + onClick = config.onClick, + ) + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffectsV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffectsV2.kt new file mode 100644 index 0000000000..d7dc0bb129 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffectsV2.kt @@ -0,0 +1,45 @@ +package com.tangem.feature.wallet.presentation.wallet.ui + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.snapshotFlow +import com.tangem.feature.wallet.presentation.wallet.ui.utils.ScrollOffsetCollectorV2 +import com.tangem.feature.wallet.presentation.wallet.ui.utils.WalletsListInteractionsCollector + +@Suppress("LongParameterList") +@Composable +internal fun WalletsListEffectsV2( + lazyListState: LazyListState, + selectedWalletIndex: Int, + onWalletChange: (Int) -> Unit, + onSelectedWalletIndexSet: (Int) -> Unit, + isAutoScroll: State, + onAutoScrollReset: () -> Unit, +) { + LaunchedEffect(key1 = lazyListState, key2 = onWalletChange) { + snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo } + .collect( + collector = ScrollOffsetCollectorV2( + selectedWalletIndex = selectedWalletIndex, + lazyListState = lazyListState, + onWalletChange = { newIndex -> + // Auto scroll must not change wallet + if (isAutoScroll.value) { + onSelectedWalletIndexSet(newIndex) + } else { + onSelectedWalletIndexSet(newIndex) + onWalletChange(newIndex) + } + }, + ), + ) + } + + LaunchedEffect(Unit) { + lazyListState.interactionSource.interactions.collect( + collector = WalletsListInteractionsCollector(onDragStart = onAutoScrollReset), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt index 7caf718821..ff70bf8486 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/WalletsList.kt @@ -23,8 +23,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.wallet.presentation.common.WalletPreviewData +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.components.WalletsListConfig import com.tangem.feature.wallet.presentation.wallet.ui.components.common.WalletCard +import kotlinx.collections.immutable.ImmutableList private const val SHORT_SNAP_ELEMENT_COUNT = 50 @@ -66,6 +68,40 @@ internal fun WalletsList(config: WalletsListConfig, lazyListState: LazyListState } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun WalletsList( + lazyListState: LazyListState, + wallets: ImmutableList, + isBalanceHidden: Boolean, +) { + val horizontalCardPadding = TangemTheme.dimens.spacing16 + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val itemWidth by remember(screenWidth) { derivedStateOf { screenWidth - horizontalCardPadding * 2 } } + + LazyRow( + modifier = Modifier.background(color = TangemTheme.colors.background.secondary), + state = lazyListState, + contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing16), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + flingBehavior = rememberWalletsFlingBehaviour(lazyListState = lazyListState, itemWidth = itemWidth), + ) { + items( + items = wallets, + key = { it.id.stringValue }, + contentType = { it.id.stringValue }, + ) { state -> + WalletCard( + state = state, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .animateItemPlacement() + .width(itemWidth), + ) + } + } +} + /** * Custom implementation of fling behaviour that overrides 'shortSnapVelocityThreshold'. * Every user's drag action will similar to a short snap diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt index 13ff628917..8ad776a987 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletContent.kt @@ -9,6 +9,8 @@ import com.tangem.feature.wallet.presentation.wallet.state.WalletMultiCurrencySt import com.tangem.feature.wallet.presentation.wallet.state.WalletSingleCurrencyState import com.tangem.feature.wallet.presentation.wallet.state.WalletState import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.tokensListItems +import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.tokensListItemsV2 +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState as WalletStateV2 /** * Wallet content @@ -29,4 +31,20 @@ internal fun LazyListScope.contentItems( is WalletMultiCurrencyState -> tokensListItems(state.tokensListState, modifier, isBalanceHidden) is WalletSingleCurrencyState -> txHistoryItems(state.txHistoryState, txHistoryItems, isBalanceHidden, modifier) } +} + +internal fun LazyListScope.contentItemsV2( + state: WalletStateV2, + txHistoryItems: LazyPagingItems?, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is WalletStateV2.MultiCurrency -> { + tokensListItemsV2(state.tokensListState, modifier, isBalanceHidden) + } + is WalletStateV2.SingleCurrency -> { + txHistoryItems(state.txHistoryState, txHistoryItems, isBalanceHidden, modifier) + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt index e60ec4b043..9e0f9f5123 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletNotifications.kt @@ -22,7 +22,7 @@ internal fun LazyListScope.notifications(configs: ImmutableList { + contentItemsV2( + items = state.items, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) + } + WalletTokensListStateV2.Empty -> nonContentItem(modifier = modifier) + } +} + private fun LazyListScope.contentItems( items: ImmutableList, modifier: Modifier = Modifier, @@ -67,6 +86,28 @@ private fun LazyListScope.contentItems( ) } +private fun LazyListScope.contentItemsV2( + items: ImmutableList, + modifier: Modifier = Modifier, + isBalanceHidden: Boolean, +) { + itemsIndexed( + items = items, + key = { _, item -> item.id }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + MultiCurrencyContentItem( + state = item, + isBalanceHidden = isBalanceHidden, + modifier = modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = items.lastIndex, + ), + ) + }, + ) +} + @OptIn(ExperimentalFoundationApi::class) private fun LazyListScope.nonContentItem(modifier: Modifier = Modifier) { item( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContentItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContentItem.kt index 9678a22d13..48f04fb8ff 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContentItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyContentItem.kt @@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.feature.wallet.presentation.common.component.NetworkGroupItem import com.tangem.feature.wallet.presentation.common.component.TokenItem import com.tangem.feature.wallet.presentation.wallet.state.components.WalletTokensListState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletTokensListState.TokensListItemState /** * Multi-currency content item @@ -29,4 +30,20 @@ internal fun MultiCurrencyContentItem( TokenItem(state = state.state, isBalanceHidden = isBalanceHidden, modifier = modifier) } } +} + +@Composable +internal fun MultiCurrencyContentItem( + state: TokensListItemState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is TokensListItemState.NetworkGroupTitle -> { + NetworkGroupItem(networkName = state.name.resolveReference(), modifier = modifier) + } + is TokensListItemState.Token -> { + TokenItem(state = state.state, isBalanceHidden = isBalanceHidden, modifier = modifier) + } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/LazyListStateExt.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/LazyListStateExt.kt new file mode 100644 index 0000000000..a9ccd192d4 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/LazyListStateExt.kt @@ -0,0 +1,24 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.utils + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState + +/** + * Animate scroll [LazyListState]. + * + * [LazyListState] method for scroll with animation by index isn't supported custom animation. + * This extension method calculate offset between [prevIndex] and [newIndex], + * and scroll by it with default animation. + */ +internal suspend fun LazyListState.animateScrollByIndex(prevIndex: Int, newIndex: Int) { + animateScrollBy( + value = calculateOffset(layoutInfo, prevIndex, newIndex), + animationSpec = tween(durationMillis = 1000), + ) +} + +private fun calculateOffset(layoutInfo: LazyListLayoutInfo, prevIndex: Int, newIndex: Int): Float { + return layoutInfo.viewportSize.width.times(other = newIndex - prevIndex).toFloat() +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ReviewManagerRequester.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ReviewManagerRequester.kt new file mode 100644 index 0000000000..a52e61dd42 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ReviewManagerRequester.kt @@ -0,0 +1,56 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.utils + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import com.google.android.play.core.review.ReviewInfo +import com.google.android.play.core.review.ReviewManager +import com.google.android.play.core.review.ReviewManagerFactory +import com.google.android.play.core.tasks.Task +import timber.log.Timber + +internal object ReviewManagerRequester { + + fun request(context: Context, onDismissClick: () -> Unit) { + val reviewManager = ReviewManagerFactory.create(context) + val requestTask = reviewManager.requestReviewFlow() + + requestTask + .addOnCompleteListener { + handleOnCompleteRequestTask( + reviewManager = reviewManager, + activity = context.findActivity(), + task = it, + onDismissClick = onDismissClick, + ) + } + .addOnFailureListener(Timber::e) + } + + private fun handleOnCompleteRequestTask( + reviewManager: ReviewManager, + activity: Activity, + task: Task, + onDismissClick: () -> Unit, + ) { + if (task.isSuccessful) { + val reviewFlow = reviewManager.launchReviewFlow(activity, task.result) + reviewFlow + .addOnCompleteListener { resultReviewTask -> + if (!resultReviewTask.isSuccessful) onDismissClick() + } + .addOnFailureListener(Timber::e) + } else { + Timber.e(task.exception) + } + } + + private fun Context.findActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + error("Permissions should be called in the context of an Activity") + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollectorV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollectorV2.kt new file mode 100644 index 0000000000..43a4af298b --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollectorV2.kt @@ -0,0 +1,49 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.utils + +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import kotlinx.coroutines.flow.FlowCollector +import kotlin.math.abs + +/** + * Flow collector for scroll items tracking. + * If first visible item offset is greater than half item size, then change selected wallet index. + * If last visible item offset is greater than half item size, then change selected wallet index. + * + * @param selectedWalletIndex selected wallet index + * @property lazyListState lazy list state + * @property onWalletChange callback that will be invoked on wallet change + * +[REDACTED_AUTHOR] + */ +internal class ScrollOffsetCollectorV2( + selectedWalletIndex: Int, + private val lazyListState: LazyListState, + private val onWalletChange: (Int) -> Unit, +) : FlowCollector> { + + private val LazyListItemInfo.halfItemSize + get() = size.div(other = 2) + + private var currentIndex = selectedWalletIndex + + override suspend fun emit(value: List) { + if (!lazyListState.isScrollInProgress || value.size <= 1) return + + val firstItem = value.firstOrNull() ?: return + val lastItem = value.lastOrNull() ?: return + + if (abs(firstItem.offset) > firstItem.halfItemSize) { + selectIndex(newIndex = firstItem.index + 1) + } else if (abs(lastItem.offset) > lastItem.halfItemSize) { + selectIndex(newIndex = lastItem.index - 1) + } + } + + private fun selectIndex(newIndex: Int) { + if (currentIndex != newIndex) { + currentIndex = newIndex + onWalletChange(newIndex) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/WalletsScrollPreviewExt.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/WalletsScrollPreviewExt.kt new file mode 100644 index 0000000000..3dbb6ccb11 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/WalletsScrollPreviewExt.kt @@ -0,0 +1,46 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.utils + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.lazy.LazyListState +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent.DemonstrateWalletsScrollPreview +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val VISIBLE_PART_OF_WALLET_CARD = 0.2f + +internal fun LazyListState.demonstrateScrolling( + coroutineScope: CoroutineScope, + direction: DemonstrateWalletsScrollPreview.Direction, +) { + coroutineScope.launch { + animateScrollBy( + value = calculateOffset(direction = direction, isReverse = false), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) + } + .invokeOnCompletion { + coroutineScope.launch { + animateScrollBy( + value = calculateOffset(direction = direction, isReverse = true), + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ) + } + } +} + +private fun LazyListState.calculateOffset( + direction: DemonstrateWalletsScrollPreview.Direction, + isReverse: Boolean, +): Float { + val sign = when (direction) { + DemonstrateWalletsScrollPreview.Direction.LEFT -> 1 + DemonstrateWalletsScrollPreview.Direction.RIGHT -> -1 + }.times(other = if (isReverse) -1 else 1) + + return layoutInfo.viewportSize.width.toFloat() * VISIBLE_PART_OF_WALLET_CARD * sign +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt index f38f77ee57..6534df2c8c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt @@ -66,6 +66,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.* import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.factory.TokenListWithWallet import com.tangem.feature.wallet.presentation.wallet.state.factory.WalletStateFactory +import com.tangem.feature.wallet.presentation.wallet.subscribers.MaybeTokenListFlow import com.tangem.operations.derivation.ExtendedPublicKeysMap import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -127,10 +128,10 @@ internal class WalletViewModel @Inject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val scanCardToUnlockWalletUseCase: ScanCardToUnlockWalletClickHandler, - hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase, isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase, isNeedToBackupUseCase: IsNeedToBackupUseCase, getMissedAddressesCryptoCurrenciesUseCase: GetMissedAddressesCryptoCurrenciesUseCase, + hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase, // endregion Parameters ) : ViewModel(), DefaultLifecycleObserver, WalletClickIntents { @@ -1338,7 +1339,7 @@ internal class WalletViewModel @Inject constructor( } } - private suspend fun updateButtons(userWallet: UserWallet, currencyStatus: CryptoCurrencyStatus) { + private fun updateButtons(userWallet: UserWallet, currencyStatus: CryptoCurrencyStatus) { getCryptoCurrencyActionsUseCase( userWallet = userWallet, cryptoCurrencyStatus = currencyStatus, @@ -1402,6 +1403,4 @@ internal class WalletViewModel @Inject constructor( } private fun getCardTypeResolver(index: Int): CardTypesResolver = getWallet(index).scanResponse.cardTypesResolver -} - -typealias MaybeTokenListFlow = Flow> \ No newline at end of file +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModelV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModelV2.kt new file mode 100644 index 0000000000..a9a2908965 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModelV2.kt @@ -0,0 +1,311 @@ +package com.tangem.feature.wallet.presentation.wallet.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import arrow.core.getOrElse +import com.tangem.common.Provider +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.IsWalletsScrollPreviewEnabled +import com.tangem.domain.settings.ShouldShowSaveWalletScreenUseCase +import com.tangem.domain.walletconnect.WalletConnectActions +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.domain.wallets.usecase.ShouldSaveUserWalletsUseCase +import com.tangem.feature.wallet.presentation.router.InnerWalletRouter +import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent +import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent.DemonstrateWalletsScrollPreview.Direction +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.* +import com.tangem.feature.wallet.presentation.wallet.state2.utils.WalletEventSender +import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntentsV2 +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import kotlin.properties.Delegates + +@Suppress("LongParameterList") +@HiltViewModel +internal class WalletViewModelV2 @Inject constructor( + private val stateHolder: WalletStateHolderV2, + private val clickIntents: WalletClickIntentsV2, + private val walletEventSender: WalletEventSender, + private val walletsUpdateActionResolver: WalletsUpdateActionResolverV2, + private val walletScreenContentLoader: WalletScreenContentLoader, + private val getSelectedWalletUseCase: GetSelectedWalletUseCase, + private val getWalletsUseCase: GetWalletsUseCase, + private val shouldShowSaveWalletScreenUseCase: ShouldShowSaveWalletScreenUseCase, + private val canUseBiometryUseCase: CanUseBiometryUseCase, + private val shouldSaveUserWalletsUseCase: ShouldSaveUserWalletsUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val isWalletsScrollPreviewEnabled: IsWalletsScrollPreviewEnabled, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + analyticsEventsHandler: AnalyticsEventHandler, + private val dispatchers: CoroutineDispatcherProvider, + private val reduxStateHolder: ReduxStateHolder, +) : ViewModel() { + + val uiState: StateFlow = stateHolder.uiState + + private val selectedAppCurrencyFlow = createSelectedAppCurrencyFlow() + + private var router: InnerWalletRouter by Delegates.notNull() + private var walletsUpdateJobHolder: JobHolder = JobHolder() + + init { + analyticsEventsHandler.send(WalletScreenAnalyticsEvent.MainScreen.ScreenOpened) + + suggestToEnableBiometrics() + + subscribeOnWalletsUpdateFlow() + subscribeOnBalanceHiding() + subscribeOnSelectedWalletFlow() + } + + fun setWalletRouter(router: InnerWalletRouter) { + this.router = router + clickIntents.initialize(router, viewModelScope) + } + + private fun suggestToEnableBiometrics() { + viewModelScope.launch(dispatchers.main) { + withContext(dispatchers.io) { delay(timeMillis = 1_800) } + + if (isShowSaveWalletScreenEnabled()) router.openSaveUserWalletScreen() + } + } + + private suspend fun isShowSaveWalletScreenEnabled(): Boolean { + return router.isWalletLastScreen() && shouldShowSaveWalletScreenUseCase() && canUseBiometryUseCase() + } + + private fun subscribeOnWalletsUpdateFlow() { + viewModelScope.launch(dispatchers.main) { + shouldSaveUserWalletsUseCase() + .conflate() + .distinctUntilChanged() + .collectLatest { shouldSaveUserWallet -> + getWalletsUseCase() + .distinctUntilChanged() + .conflate() + .map { + walletsUpdateActionResolver.resolve( + wallets = it, + currentState = stateHolder.value, + canSaveWallets = shouldSaveUserWallet, + ) + } + .onEach(::updateWallets) + .flowOn(dispatchers.main) + .launchIn(viewModelScope) + .saveIn(walletsUpdateJobHolder) + } + } + } + + private fun subscribeOnBalanceHiding() { + getBalanceHidingSettingsUseCase() + .conflate() + .distinctUntilChanged() + .onEach { + stateHolder.update(transformer = UpdateBalanceHidingModeTransformer(it.isBalanceHidden)) + } + .flowOn(dispatchers.main) + .launchIn(viewModelScope) + } + + private fun subscribeOnSelectedWalletFlow() { + getSelectedWalletUseCase().onRight { + it + .conflate() + .distinctUntilChanged() + .onEach { selectedWallet -> + if (selectedWallet.isMultiCurrency) { + reduxStateHolder.dispatch( + action = WalletConnectActions.New.Initialize(userWallet = selectedWallet), + ) + } + } + .flowOn(dispatchers.main) + .launchIn(viewModelScope) + } + } + + private fun updateWallets(action: WalletsUpdateActionResolverV2.Action) { + when (action) { + is WalletsUpdateActionResolverV2.Action.InitializeWallets -> initializeWallets(action) + is WalletsUpdateActionResolverV2.Action.ReinitializeWallets -> { + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + appCurrency = selectedAppCurrencyFlow.value, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + isRefresh = true, + ) + } + is WalletsUpdateActionResolverV2.Action.ReinitializeWallet -> reinitializeWallet(action) + is WalletsUpdateActionResolverV2.Action.AddWallet -> addWallet(action) + is WalletsUpdateActionResolverV2.Action.DeleteWallet -> deleteWallet(action) + is WalletsUpdateActionResolverV2.Action.UnlockWallet -> unlockWallet(action) + is WalletsUpdateActionResolverV2.Action.UpdateWalletCardCount -> { + stateHolder.update(transformer = UpdateWalletCardsCountTransformer(action.selectedWallet)) + } + is WalletsUpdateActionResolverV2.Action.UpdateWalletName -> { + stateHolder.update(transformer = RenameWalletTransformer(action.selectedWalletId, action.name)) + } + is WalletsUpdateActionResolverV2.Action.Unknown -> Unit + } + } + + private fun initializeWallets(action: WalletsUpdateActionResolverV2.Action.InitializeWallets) { + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + appCurrency = selectedAppCurrencyFlow.value, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) + + stateHolder.update( + transformer = InitializeWalletsTransformer( + selectedWalletIndex = action.selectedWalletIndex, + selectedWallet = action.selectedWallet, + wallets = action.wallets, + clickIntents = clickIntents, + ), + ) + + viewModelScope.launch(dispatchers.main) { + if (action.wallets.size > 1 && isWalletsScrollPreviewEnabled()) { + withContext(dispatchers.io) { + delay(timeMillis = 1_800) + } + + walletEventSender.send( + event = WalletEvent.DemonstrateWalletsScrollPreview( + direction = if (action.selectedWalletIndex == action.wallets.lastIndex) { + Direction.RIGHT + } else { + Direction.LEFT + }, + ), + ) + } + } + } + + private fun reinitializeWallet(action: WalletsUpdateActionResolverV2.Action.ReinitializeWallet) { + viewModelScope.launch(dispatchers.main) { + walletScreenContentLoader.cancel(action.prevWalletId) + + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + appCurrency = selectedAppCurrencyFlow.value, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) + + stateHolder.update( + ReinitializeWalletTransformer(userWallet = action.selectedWallet, clickIntents = clickIntents), + ) + } + } + + private fun addWallet(action: WalletsUpdateActionResolverV2.Action.AddWallet) { + viewModelScope.launch(dispatchers.main) { + stateHolder.update( + AddWalletTransformer( + userWallet = action.selectedWallet, + clickIntents = clickIntents, + ), + ) + + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + appCurrency = selectedAppCurrencyFlow.value, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) + + withContext(dispatchers.io) { delay(timeMillis = 700) } + + scrollToWallet(index = action.selectedWalletIndex) + } + } + + private fun deleteWallet(action: WalletsUpdateActionResolverV2.Action.DeleteWallet) { + viewModelScope.launch(dispatchers.main) { + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + appCurrency = selectedAppCurrencyFlow.value, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) + + scrollToWallet(index = action.selectedWalletIndex) + + withContext(dispatchers.io) { delay(timeMillis = 700) } + + stateHolder.update( + DeleteWalletTransformer( + selectedWalletIndex = action.selectedWalletIndex, + deletedWalletId = action.deletedWalletId, + ), + ) + } + } + + private fun unlockWallet(action: WalletsUpdateActionResolverV2.Action.UnlockWallet) { + viewModelScope.launch(dispatchers.main) { + withContext(dispatchers.io) { delay(timeMillis = 700) } + + stateHolder.update( + transformer = UnlockWalletTransformer( + unlockedWallets = action.unlockedWallets, + clickIntents = clickIntents, + ), + ) + + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + appCurrency = selectedAppCurrencyFlow.value, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) + } + } + + private fun scrollToWallet(index: Int) { + stateHolder.update( + ScrollToWalletTransformer( + index = index, + currentStateProvider = Provider(action = stateHolder::value), + stateUpdater = { newState -> stateHolder.update { newState } }, + ), + ) + } + + private fun createSelectedAppCurrencyFlow(): StateFlow { + return getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolverV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolverV2.kt new file mode 100644 index 0000000000..821148d80c --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletsUpdateActionResolverV2.kt @@ -0,0 +1,305 @@ +package com.tangem.feature.wallet.presentation.wallet.viewmodels + +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.wallet.presentation.wallet.domain.getCardsCount +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state2.NOT_INITIALIZED_WALLET_INDEX +import com.tangem.feature.wallet.presentation.wallet.state2.WalletScreenState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import dagger.hilt.android.scopes.ViewModelScoped +import timber.log.Timber +import javax.inject.Inject + +/** + * Resolver that determines which update action will be performed + * + * @property getSelectedWalletSyncUseCase use case that returns selected wallet + */ +@ViewModelScoped +internal class WalletsUpdateActionResolverV2 @Inject constructor( + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, +) { + + private var isInitialized: Boolean = false + private var canSaveWallets: Boolean = false + + fun resolve(wallets: List, currentState: WalletScreenState, canSaveWallets: Boolean): Action { + val selectedWallet = wallets.getSelectedWallet() ?: return Action.Unknown + + val action = when { + isFirstInitialization(currentState) -> { + createInitializeWalletsAction(wallets, selectedWallet, canSaveWallets) + } + isReinitialization(canSaveWallets) -> { + this.canSaveWallets = canSaveWallets + Action.ReinitializeWallets(selectedWallet = selectedWallet) + } + else -> getUpdateContentAction(currentState, wallets, selectedWallet) + } + + Timber.d("Resolved action: $action") + + return action + } + + private fun List.getSelectedWallet(): UserWallet? { + return when { + isEmpty() -> null + size == 1 -> first() + else -> getSelectedWalletSyncUseCase().fold(ifLeft = { null }, ifRight = { it }) + } + } + + private fun isFirstInitialization(state: WalletScreenState): Boolean { + return state.selectedWalletIndex == NOT_INITIALIZED_WALLET_INDEX + } + + private fun createInitializeWalletsAction( + wallets: List, + selectedWallet: UserWallet, + canSaveWallets: Boolean, + ): Action { + this.isInitialized = true + this.canSaveWallets = canSaveWallets + + return Action.InitializeWallets( + selectedWalletIndex = wallets.indexOfWallet(selectedWallet.walletId), + selectedWallet = selectedWallet, + wallets = wallets, + ) + } + + private fun isReinitialization(canSaveWallets: Boolean): Boolean { + return isInitialized && this.canSaveWallets != canSaveWallets + } + + private fun getUpdateContentAction( + state: WalletScreenState, + wallets: List, + selectedWallet: UserWallet, + ): Action { + return when { + isWalletsCountChanged(state, wallets) -> { + getChangeWalletsListAction(state, wallets, selectedWallet) + } + isAnotherWalletSelected(state, selectedWallet) -> { + Action.ReinitializeWallet( + prevWalletId = state.getPrevSelectedWallet().id, + selectedWallet = selectedWallet, + ) + } + else -> getUpdateSelectedWalletAction(state, wallets, selectedWallet) + } + } + + private fun isWalletsCountChanged(state: WalletScreenState, wallets: List): Boolean { + val prevWalletsSize = state.wallets.size + val walletsSize = wallets.size + + return prevWalletsSize != walletsSize + } + + private fun getChangeWalletsListAction( + state: WalletScreenState, + wallets: List, + selectedWallet: UserWallet, + ): Action { + val prevWalletsSize = state.wallets.size + + return when { + prevWalletsSize > wallets.size -> { + Action.DeleteWallet( + selectedWallet = selectedWallet, + selectedWalletIndex = wallets.indexOfWallet(id = selectedWallet.walletId), + deletedWalletId = state.wallets.getDeletedWalletId(wallets), + ) + } + prevWalletsSize < wallets.size -> { + val newUserWallet = state.wallets.getAddedWallet(wallets) + Action.AddWallet( + selectedWalletIndex = wallets.indexOfWallet(id = newUserWallet.walletId), + selectedWallet = newUserWallet, + ) + } + else -> error("Wallets list is not changed") + } + } + + private fun List.getDeletedWalletId(wallets: List): UserWalletId { + return this + .map { it.walletCardState.id } + .firstOrNull { !wallets.map(UserWallet::walletId).contains(it) } + ?: error("Deleted wallet id is not found. Wallets contains all previous wallets ids") + } + + private fun List.getAddedWallet(wallets: List): UserWallet { + return wallets + .firstOrNull { wallet -> !this.map { it.walletCardState.id }.contains(wallet.walletId) } + ?: error("Added wallet id is not found. Wallets contains all previous wallets ids") + } + + private fun isAnotherWalletSelected(state: WalletScreenState, selectedWallet: UserWallet): Boolean { + return state.getPrevSelectedWallet().id != selectedWallet.walletId + } + + private fun getUpdateSelectedWalletAction( + state: WalletScreenState, + wallets: List, + selectedWallet: UserWallet, + ): Action { + return when { + isSelectedWalletNameChanged(state, selectedWallet) -> { + Action.UpdateWalletName(selectedWalletId = selectedWallet.walletId, name = selectedWallet.name) + } + isSelectedWalletUnlocked(state, selectedWallet) -> { + Action.UnlockWallet( + selectedWallet = selectedWallet, + unlockedWallets = wallets.filterNot(UserWallet::isLocked), + ) + } + isSelectedWalletCardsCountChanged(state, selectedWallet) -> Action.UpdateWalletCardCount(selectedWallet) + else -> Action.Unknown + } + } + + private fun isSelectedWalletNameChanged(state: WalletScreenState, selectedWallet: UserWallet): Boolean { + return state.getPrevSelectedWallet().title != selectedWallet.name + } + + private fun isSelectedWalletUnlocked(state: WalletScreenState, selectedWallet: UserWallet): Boolean { + return state.isSelectedWalletLocked() && !selectedWallet.isLocked + } + + private fun isSelectedWalletCardsCountChanged(state: WalletScreenState, selectedWallet: UserWallet): Boolean { + val prevSelectedWallet = state.getPrevSelectedWallet() + return prevSelectedWallet is WalletCardState.Content && + prevSelectedWallet.cardCount != selectedWallet.getCardsCount() + } + + private fun WalletScreenState.isSelectedWalletLocked(): Boolean { + val selectedWalletState = wallets.getOrNull(selectedWalletIndex) ?: error("Selected wallet is not found") + return selectedWalletState is WalletState.MultiCurrency.Locked || + selectedWalletState is WalletState.SingleCurrency.Locked + } + + private fun WalletScreenState.getPrevSelectedWallet(): WalletCardState { + return wallets + .map(WalletState::walletCardState) + .getOrNull(selectedWalletIndex) + ?: error("Previous selected wallet is not found") + } + + private fun List.indexOfWallet(id: UserWalletId): Int { + val selectedIndex = indexOfFirst { it.walletId == id } + + return if (selectedIndex == -1) { + error("Wallets don't contain a wallet with id: $id") + } else { + selectedIndex + } + } + + sealed class Action { + + data class InitializeWallets( + val selectedWalletIndex: Int, + val selectedWallet: UserWallet, + val wallets: List, + ) : Action() { + + override fun toString(): String { + return """ + Initialize( + selectedWalletIndex=$selectedWalletIndex, + selectedWallet=${selectedWallet.walletId}, + wallets=${wallets.joinToString { it.walletId.toString() }} + ) + """.trimIndent() + } + } + + /** + * Reinitialize wallets. Example, if user turned on wallets saving + * + * @property selectedWallet selected wallet + */ + data class ReinitializeWallets(val selectedWallet: UserWallet) : Action() { + + override fun toString(): String { + return "Reinitialize(selectedWallet=${selectedWallet.walletId})" + } + } + + /** + * Reinitialize selected wallet. Example, scanning a new card if wallets saving is turned off + * + * @property prevWalletId previous selected wallet id + * @property selectedWallet selected wallet + */ + data class ReinitializeWallet(val prevWalletId: UserWalletId, val selectedWallet: UserWallet) : Action() { + + override fun toString(): String { + return "ReinitializeWallet(prevWalletId=$prevWalletId, selectedWallet=${selectedWallet.walletId})" + } + } + + data class UpdateWalletName(val selectedWalletId: UserWalletId, val name: String) : Action() { + + override fun toString(): String { + return "UpdateWalletName(selectedWalletId=$selectedWalletId, name=$name)" + } + } + + data class UnlockWallet(val selectedWallet: UserWallet, val unlockedWallets: List) : Action() { + + override fun toString(): String { + return """ + UnlockWallet( + selectedWallet=${selectedWallet.walletId}, + unlockedWallets=${unlockedWallets.joinToString { it.walletId.toString() }} + ) + """.trimIndent() + } + } + + data class DeleteWallet( + val selectedWallet: UserWallet, + val selectedWalletIndex: Int, + val deletedWalletId: UserWalletId, + ) : Action() { + + override fun toString(): String { + return """ + DeleteWallet( + selectedWallet=${selectedWallet.walletId}, + selectedWalletIndex=$selectedWalletIndex, + deletedWalletId=$deletedWalletId + ) + """.trimIndent() + } + } + + data class AddWallet(val selectedWalletIndex: Int, val selectedWallet: UserWallet) : Action() { + + override fun toString(): String { + return "AddWallet(selectedWalletIndex=$selectedWalletIndex, selectedWallet=${selectedWallet.walletId})" + } + } + + /** + * Update wallet card count. Example, if user backed up wallet + * + * @property selectedWallet selected wallet + */ + data class UpdateWalletCardCount(val selectedWallet: UserWallet) : Action() { + + override fun toString(): String { + return "UpdateWalletCardCount(selectedWallet=${selectedWallet.walletId})" + } + } + + object Unknown : Action() + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/BaseWalletClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/BaseWalletClickIntents.kt new file mode 100644 index 0000000000..d9be6984f5 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/BaseWalletClickIntents.kt @@ -0,0 +1,26 @@ +package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents + +import com.tangem.feature.wallet.presentation.router.InnerWalletRouter +import kotlinx.coroutines.CoroutineScope +import kotlin.properties.Delegates + +/** + * Base wallet click intents component. + * Provides router and viewModelScope to child classes. + * +[REDACTED_AUTHOR] + */ +@Suppress("UnnecessaryAbstractClass") +internal abstract class BaseWalletClickIntents { + + protected val router: InnerWalletRouter get() = _router + protected val viewModelScope: CoroutineScope get() = _viewModelScope + + private var _router: InnerWalletRouter by Delegates.notNull() + private var _viewModelScope: CoroutineScope by Delegates.notNull() + + open fun initialize(router: InnerWalletRouter, coroutineScope: CoroutineScope) { + _router = router + _viewModelScope = coroutineScope + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCardClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCardClickIntents.kt new file mode 100644 index 0000000000..1bcff88b81 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCardClickIntents.kt @@ -0,0 +1,72 @@ +package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents + +import com.tangem.core.navigation.AppScreen +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.usecase.DeleteWalletUseCase +import com.tangem.domain.wallets.usecase.UpdateWalletUseCase +import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader +import com.tangem.feature.wallet.presentation.wallet.state.WalletAlertState +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.state.components.WalletCardState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.utils.WalletEventSender +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +internal interface WalletCardClickIntents { + + fun onRenameClick(userWalletId: UserWalletId, name: String) + + fun onDeleteBeforeConfirmationClick(userWalletId: UserWalletId) + + fun onDeleteAfterConfirmationClick(userWalletId: UserWalletId) +} + +internal class WalletCardClickIntentsImplementor @Inject constructor( + private val stateHolder: WalletStateHolderV2, + private val walletEventSender: WalletEventSender, + private val walletScreenContentLoader: WalletScreenContentLoader, + private val updateWalletUseCase: UpdateWalletUseCase, + private val deleteWalletUseCase: DeleteWalletUseCase, + private val dispatchers: CoroutineDispatcherProvider, +) : BaseWalletClickIntents(), WalletCardClickIntents { + + override fun onRenameClick(userWalletId: UserWalletId, name: String) { + viewModelScope.launch(dispatchers.main) { + updateWalletUseCase(userWalletId = userWalletId, update = { it.copy(name) }) + } + } + + override fun onDeleteBeforeConfirmationClick(userWalletId: UserWalletId) { + walletEventSender.send( + event = WalletEvent.ShowAlert( + state = WalletAlertState.RemoveWalletAlert( + onConfirmClick = { onDeleteAfterConfirmationClick(userWalletId) }, + ), + ), + ) + } + + override fun onDeleteAfterConfirmationClick(userWalletId: UserWalletId) { + viewModelScope.launch(dispatchers.main) { + walletScreenContentLoader.cancel(userWalletId) + deleteWalletUseCase(userWalletId) + .onRight { popBackIfAllWalletsIsLocked() } + .onLeft { Timber.e(it.toString()) } + } + } + + private fun popBackIfAllWalletsIsLocked() { + val wallets = stateHolder.value.wallets.map(WalletState::walletCardState) + val unlockedWallet = wallets.count { it !is WalletCardState.LockedContent } + + if (unlockedWallet == 1) { + router.popBackStack( + screen = if (wallets.size > 1) AppScreen.Welcome else AppScreen.Home, + ) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletClickIntentsV2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletClickIntentsV2.kt new file mode 100644 index 0000000000..55e0a76bc5 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletClickIntentsV2.kt @@ -0,0 +1,149 @@ +package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.settings.NeverToShowWalletsScrollPreview +import com.tangem.domain.tokens.FetchCardTokenListUseCase +import com.tangem.domain.tokens.FetchCurrencyStatusUseCase +import com.tangem.domain.tokens.FetchTokenListUseCase +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.analytics.PortfolioEvent +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.state2.WalletState +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetRefreshStateTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.SetTokenListErrorTransformer +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Suppress("LongParameterList") +@ViewModelScoped +internal class WalletClickIntentsV2 @Inject constructor( + private val walletCardClickIntentsImplementor: WalletCardClickIntentsImplementor, + private val warningsClickIntentsImplementer: WalletWarningsClickIntentsImplementer, + private val currencyActionsClickIntentsImplementor: WalletCurrencyActionsClickIntentsImplementor, + private val contentClickIntentsImplementor: WalletContentClickIntentsImplementor, + private val stateHolder: WalletStateHolderV2, + private val walletScreenContentLoader: WalletScreenContentLoader, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val selectWalletUseCase: SelectWalletUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val fetchTokenListUseCase: FetchTokenListUseCase, + private val fetchCardTokenListUseCase: FetchCardTokenListUseCase, + private val fetchCurrencyStatusUseCase: FetchCurrencyStatusUseCase, + private val neverToShowWalletsScrollPreview: NeverToShowWalletsScrollPreview, + private val analyticsEventHandler: AnalyticsEventHandler, + private val dispatchers: CoroutineDispatcherProvider, +) : BaseWalletClickIntents(), + WalletCardClickIntents by walletCardClickIntentsImplementor, + WalletWarningsClickIntents by warningsClickIntentsImplementer, + WalletCurrencyActionsClickIntents by currencyActionsClickIntentsImplementor, + WalletContentClickIntents by contentClickIntentsImplementor { + + override fun initialize(router: InnerWalletRouter, coroutineScope: CoroutineScope) { + super.initialize(router, coroutineScope) + + walletCardClickIntentsImplementor.initialize(router, coroutineScope) + warningsClickIntentsImplementer.initialize(router, coroutineScope) + currencyActionsClickIntentsImplementor.initialize(router, coroutineScope) + contentClickIntentsImplementor.initialize(router, coroutineScope) + } + + fun onWalletChange(index: Int) { + viewModelScope.launch(dispatchers.main) { + launch(dispatchers.main) { neverToShowWalletsScrollPreview() } + + val maybeUserWallet = selectWalletUseCase( + userWalletId = stateHolder.value.wallets[index].walletCardState.id, + ) + + stateHolder.update { it.copy(selectedWalletIndex = index) } + + maybeUserWallet.onRight { + walletScreenContentLoader.load( + userWallet = it, + appCurrency = getSelectedAppCurrencyUseCase.unwrap(), + clickIntents = this@WalletClickIntentsV2, + coroutineScope = viewModelScope, + ) + } + } + } + + fun onRefreshSwipe() { + when (stateHolder.getSelectedWallet()) { + is WalletState.MultiCurrency.Content -> { + analyticsEventHandler.send(PortfolioEvent.Refreshed) + refreshMultiCurrencyContent() + } + is WalletState.SingleCurrency.Content -> { + analyticsEventHandler.send(PortfolioEvent.Refreshed) + refreshSingleCurrencyContent() + } + is WalletState.MultiCurrency.Locked, + is WalletState.SingleCurrency.Locked, + -> Unit + } + } + + private fun refreshMultiCurrencyContent() { + val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return + + stateHolder.update( + SetRefreshStateTransformer(userWalletId = userWallet.walletId, isRefreshing = true), + ) + + viewModelScope.launch(dispatchers.main) { + val maybeFetchResult = if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) { + fetchCardTokenListUseCase(userWalletId = userWallet.walletId, refresh = true) + } else { + fetchTokenListUseCase(userWalletId = userWallet.walletId, refresh = true) + } + + maybeFetchResult.onLeft { + stateHolder.update(SetTokenListErrorTransformer(userWalletId = userWallet.walletId, error = it)) + } + + stateHolder.update( + SetRefreshStateTransformer(userWalletId = userWallet.walletId, isRefreshing = false), + ) + } + } + + fun onReloadClick() { + refreshSingleCurrencyContent() + } + + // FIXME: refreshSingleCurrencyContent mustn't update the TxHistory and Buttons. It only must fetch primary + // currency. Now it not works because GetPrimaryCurrency's subscriber uses .distinctUntilChanged() + private fun refreshSingleCurrencyContent() { + val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return + + stateHolder.update( + SetRefreshStateTransformer(userWalletId = userWallet.walletId, isRefreshing = true), + ) + + viewModelScope.launch(dispatchers.main) { + fetchCurrencyStatusUseCase(userWallet.walletId, refresh = true) + + walletScreenContentLoader.load( + userWallet = userWallet, + appCurrency = getSelectedAppCurrencyUseCase.unwrap(), + clickIntents = this@WalletClickIntentsV2, + coroutineScope = viewModelScope, + isRefresh = true, + ) + + stateHolder.update( + SetRefreshStateTransformer(userWalletId = userWallet.walletId, isRefreshing = false), + ) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletContentClickIntents.kt new file mode 100644 index 0000000000..a3582c6e63 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletContentClickIntents.kt @@ -0,0 +1,123 @@ +package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.TokensAction +import com.tangem.domain.tokens.model.CryptoCurrency +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.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.wallet.presentation.wallet.analytics.PortfolioEvent +import com.tangem.feature.wallet.presentation.wallet.domain.unwrap +import com.tangem.feature.wallet.presentation.wallet.state.ActionsBottomSheetConfig +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.CloseBottomSheetTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.OpenBottomSheetTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.converter.MultiWalletCurrencyActionsConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal interface WalletContentClickIntents { + + fun onBackClick() + + fun onDetailsClick() + + fun onManageTokensClick() + + fun onOrganizeTokensClick() + + fun onTokenItemClick(currency: CryptoCurrency) + + fun onTokenItemLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onTransactionClick(txHash: String) +} + +@Suppress("LongParameterList") +@ViewModelScoped +internal class WalletContentClickIntentsImplementor @Inject constructor( + private val stateHolder: WalletStateHolderV2, + private val currencyActionsClickIntentsImplementor: WalletCurrencyActionsClickIntentsImplementor, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase, + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, + private val dispatchers: CoroutineDispatcherProvider, + private val reduxStateHolder: ReduxStateHolder, +) : BaseWalletClickIntents(), WalletContentClickIntents { + + override fun onBackClick() = router.popBackStack() + + override fun onDetailsClick() = router.openDetailsScreen() + + override fun onManageTokensClick() { + analyticsEventHandler.send(PortfolioEvent.ButtonManageTokens) + reduxStateHolder.dispatch(action = TokensAction.SetArgs.ManageAccess) + router.openManageTokensScreen() + } + + override fun onOrganizeTokensClick() { + analyticsEventHandler.send(PortfolioEvent.OrganizeTokens) + router.openOrganizeTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) + } + + override fun onTokenItemClick(currency: CryptoCurrency) { + analyticsEventHandler.send(PortfolioEvent.TokenTapped) + router.openTokenDetails(stateHolder.getSelectedWalletId(), currency) + } + + override fun onTokenItemLongClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return + + viewModelScope.launch(dispatchers.main) { + getCryptoCurrencyActionsUseCase(userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus) + .take(count = 1) + .collectLatest { + showActionsBottomSheet(it, userWallet) + } + } + } + + private fun showActionsBottomSheet(tokenActionsState: TokenActionsState, userWallet: UserWallet) { + stateHolder.update( + OpenBottomSheetTransformer( + userWalletId = userWallet.walletId, + content = ActionsBottomSheetConfig( + actions = MultiWalletCurrencyActionsConverter( + userWallet = userWallet, + clickIntents = currencyActionsClickIntentsImplementor, + ).convert(tokenActionsState), + ), + onDismissBottomSheet = { + stateHolder.update( + CloseBottomSheetTransformer(userWalletId = userWallet.walletId), + ) + }, + ), + ) + } + + override fun onTransactionClick(txHash: String) { + viewModelScope.launch(dispatchers.main) { + val currency = getPrimaryCurrencyStatusUpdatesUseCase.unwrap( + userWalletId = stateHolder.getSelectedWalletId(), + ) + ?.currency + ?: return@launch + + router.openUrl( + url = getExplorerTransactionUrlUseCase(txHash = txHash, networkId = currency.network.id), + ) + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt new file mode 100644 index 0000000000..789557c99e --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletCurrencyActionsClickIntents.kt @@ -0,0 +1,410 @@ +package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents + +import com.tangem.blockchain.common.address.AddressType +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.common.util.cardTypesResolver +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase +import com.tangem.domain.tokens.GetPrimaryCurrencyStatusUpdatesUseCase +import com.tangem.domain.tokens.IsCryptoCurrencyCoinCouldHideUseCase +import com.tangem.domain.tokens.RemoveCurrencyUseCase +import com.tangem.domain.tokens.legacy.TradeCryptoAction +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.tokens.model.CryptoCurrencyStatus +import com.tangem.domain.tokens.model.NetworkAddress +import com.tangem.domain.tokens.models.analytics.TokenReceiveAnalyticsEvent +import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent +import com.tangem.domain.walletconnect.WalletConnectActions +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.wallet.domain.unwrap +import com.tangem.feature.wallet.presentation.wallet.state.WalletAlertState +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.CloseBottomSheetTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.OpenBottomSheetTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.utils.WalletEventSender +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface WalletCurrencyActionsClickIntents { + + fun onSendClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onCopyAddressClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onHideTokensClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onPerformHideToken(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onSellClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onBuyClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onSwapClick(cryptoCurrencyStatus: CryptoCurrencyStatus) + + fun onExploreClick() +} + +@Suppress("LongParameterList", "LargeClass") +@ViewModelScoped +internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( + private val stateHolder: WalletStateHolderV2, + private val walletEventSender: WalletEventSender, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val walletManagersFacade: WalletManagersFacade, + private val isDemoCardUseCase: IsDemoCardUseCase, + private val getPrimaryCurrencyStatusUpdatesUseCase: GetPrimaryCurrencyStatusUpdatesUseCase, + private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase, + private val removeCurrencyUseCase: RemoveCurrencyUseCase, + private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase, + private val getExploreUrlUseCase: GetExploreUrlUseCase, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, + private val dispatchers: CoroutineDispatcherProvider, + private val reduxStateHolder: ReduxStateHolder, +) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents { + + override fun onSendClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return + + analyticsEventHandler.send( + event = TokenScreenAnalyticsEvent.ButtonSend(cryptoCurrencyStatus.currency.symbol), + ) + + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWallet.walletId)) + + when (val currency = cryptoCurrencyStatus.currency) { + is CryptoCurrency.Coin -> sendCoin(cryptoCurrencyStatus, userWallet) + is CryptoCurrency.Token -> sendToken(currency, cryptoCurrencyStatus.value, userWallet) + } + } + + private fun sendCoin(cryptoCurrencyStatus: CryptoCurrencyStatus, userWallet: UserWallet) { + reduxStateHolder.dispatch( + action = TradeCryptoAction.New.SendCoin(userWallet = userWallet, coinStatus = cryptoCurrencyStatus), + ) + } + + private fun sendToken( + cryptoCurrency: CryptoCurrency.Token, + cryptoCurrencyStatus: CryptoCurrencyStatus.Status, + userWallet: UserWallet, + ) { + viewModelScope.launch(dispatchers.main) { + getNetworkCoinStatusUseCase( + userWalletId = userWallet.walletId, + networkId = cryptoCurrency.network.id, + derivationPath = cryptoCurrency.network.derivationPath, + isSingleWalletWithTokens = userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken(), + ) + .take(count = 1) + .collectLatest { + it.onRight { coinStatus -> + reduxStateHolder.dispatch( + action = TradeCryptoAction.New.SendToken( + userWallet = userWallet, + tokenCurrency = cryptoCurrency, + tokenFiatRate = cryptoCurrencyStatus.fiatRate, + coinFiatRate = coinStatus.value.fiatRate, + ), + ) + } + } + } + } + + override fun onReceiveClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + val userWalletId = stateHolder.getSelectedWalletId() + + analyticsEventHandler.send( + event = TokenScreenAnalyticsEvent.ButtonReceive(cryptoCurrencyStatus.currency.symbol), + ) + + viewModelScope.launch(dispatchers.main) { + analyticsEventHandler.send(event = TokenReceiveAnalyticsEvent.ReceiveScreenOpened) + + stateHolder.update( + OpenBottomSheetTransformer( + userWalletId = userWalletId, + content = createReceiveBottomSheetContent( + currency = cryptoCurrencyStatus.currency, + addresses = cryptoCurrencyStatus.value.networkAddress?.availableAddresses ?: return@launch, + ), + onDismissBottomSheet = { + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) + }, + ), + ) + } + } + + private fun createReceiveBottomSheetContent( + currency: CryptoCurrency, + addresses: Set, + ): TangemBottomSheetConfigContent { + return TokenReceiveBottomSheetConfig( + name = currency.name, + symbol = currency.symbol, + network = currency.network.name, + addresses = addresses.mapToAddressModels(currency).toImmutableList(), + onCopyClick = { + analyticsEventHandler.send(TokenReceiveAnalyticsEvent.ButtonCopyAddress(currency.symbol)) + }, + onShareClick = { + analyticsEventHandler.send(TokenReceiveAnalyticsEvent.ButtonShareAddress(currency.symbol)) + }, + ) + } + + override fun onCopyAddressClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + analyticsEventHandler.send( + event = TokenScreenAnalyticsEvent.ButtonCopyAddress(cryptoCurrencyStatus.currency.symbol), + ) + + viewModelScope.launch(dispatchers.main) { + walletManagersFacade.getAddress( + userWalletId = stateHolder.getSelectedWalletId(), + network = cryptoCurrencyStatus.currency.network, + ) + .find { it.type == AddressType.Default } + ?.value + ?.let { + walletEventSender.send( + event = WalletEvent.CopyAddress( + address = it, + toast = resourceReference(R.string.wallet_notification_address_copied), + ), + ) + } + } + } + + override fun onHideTokensClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + analyticsEventHandler.send( + event = TokenScreenAnalyticsEvent.ButtonRemoveToken(cryptoCurrencyStatus.currency.symbol), + ) + + viewModelScope.launch(dispatchers.main) { + walletEventSender.send( + event = WalletEvent.ShowAlert( + state = getHideTokeAlertConfig(stateHolder.getSelectedWalletId(), cryptoCurrencyStatus), + ), + ) + } + } + + private suspend fun getHideTokeAlertConfig( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): WalletAlertState.DefaultAlert { + val currency = cryptoCurrencyStatus.currency + return if (currency is CryptoCurrency.Coin && !isCryptoCurrencyCoinCouldHide(userWalletId, currency)) { + WalletAlertState.DefaultAlert( + title = resourceReference( + id = R.string.token_details_unable_hide_alert_title, + formatArgs = WrappedList(listOf(cryptoCurrencyStatus.currency.name)), + ), + message = resourceReference( + id = R.string.token_details_unable_hide_alert_message, + formatArgs = WrappedList( + listOf( + cryptoCurrencyStatus.currency.name, + cryptoCurrencyStatus.currency.network.name, + ), + ), + ), + onConfirmClick = null, + ) + } else { + WalletAlertState.DefaultAlert( + title = resourceReference( + id = R.string.token_details_hide_alert_title, + formatArgs = WrappedList(listOf(cryptoCurrencyStatus.currency.name)), + ), + message = resourceReference(R.string.token_details_hide_alert_message), + onConfirmClick = { onPerformHideToken(cryptoCurrencyStatus) }, + ) + } + } + + override fun onPerformHideToken(cryptoCurrencyStatus: CryptoCurrencyStatus) { + val userWalletId = stateHolder.getSelectedWalletId() + + viewModelScope.launch(dispatchers.io) { + removeCurrencyUseCase(userWalletId, cryptoCurrencyStatus.currency) + .fold( + ifLeft = { + walletEventSender.send( + event = WalletEvent.ShowToast(text = resourceReference(R.string.common_error)), + ) + }, + ifRight = { + getSelectedWalletSyncUseCase.unwrap()?.let { userWallet -> + reduxStateHolder.dispatch( + action = WalletConnectActions.New.SetupUserChains(userWallet = userWallet), + ) + } + + stateHolder.update(CloseBottomSheetTransformer(userWalletId = userWalletId)) + }, + ) + } + } + + override fun onSellClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + analyticsEventHandler.send( + event = TokenScreenAnalyticsEvent.ButtonSell(cryptoCurrencyStatus.currency.symbol), + ) + + showErrorIfDemoModeOrElse { + viewModelScope.launch(dispatchers.main) { + reduxStateHolder.dispatch( + action = TradeCryptoAction.New.Sell( + cryptoCurrencyStatus = cryptoCurrencyStatus, + appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code, + ), + ) + } + } + } + + override fun onBuyClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return + + analyticsEventHandler.send( + event = TokenScreenAnalyticsEvent.ButtonBuy(cryptoCurrencyStatus.currency.symbol), + ) + + showErrorIfDemoModeOrElse { + viewModelScope.launch(dispatchers.main) { + reduxStateHolder.dispatch( + TradeCryptoAction.New.Buy( + userWallet = userWallet, + cryptoCurrencyStatus = cryptoCurrencyStatus, + appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code, + ), + ) + } + } + } + + override fun onSwapClick(cryptoCurrencyStatus: CryptoCurrencyStatus) { + analyticsEventHandler.send( + event = TokenScreenAnalyticsEvent.ButtonExchange(cryptoCurrencyStatus.currency.symbol), + ) + + reduxStateHolder.dispatch(TradeCryptoAction.New.Swap(cryptoCurrencyStatus.currency)) + } + + override fun onExploreClick() { + showErrorIfDemoModeOrElse(action = ::openExplorer) + } + + private fun openExplorer() { + val userWalletId = stateHolder.getSelectedWalletId() + + viewModelScope.launch(dispatchers.main) { + val currencyStatus = getPrimaryCurrencyStatusUpdatesUseCase.unwrap(userWalletId) ?: return@launch + + when (val addresses = currencyStatus.value.networkAddress) { + is NetworkAddress.Selectable -> { + showChooseAddressBottomSheet(userWalletId, addresses.availableAddresses, currencyStatus.currency) + } + is NetworkAddress.Single -> { + router.openUrl( + url = getExploreUrlUseCase( + userWalletId = userWalletId, + currency = currencyStatus.currency, + addressType = AddressType.Default, + ), + ) + } + null -> Unit + } + } + } + + private fun showChooseAddressBottomSheet( + userWalletId: UserWalletId, + addresses: Set, + currency: CryptoCurrency, + ) { + stateHolder.update( + OpenBottomSheetTransformer( + userWalletId = userWalletId, + content = ChooseAddressBottomSheetConfig( + addressModels = addresses.mapToAddressModels(currency).toImmutableList(), + onClick = { + onAddressTypeSelected( + userWalletId = userWalletId, + currency = currency, + addressModel = it, + ) + }, + ), + onDismissBottomSheet = { + stateHolder.update( + CloseBottomSheetTransformer(userWalletId = userWalletId), + ) + }, + ), + ) + } + + private fun onAddressTypeSelected( + userWalletId: UserWalletId, + currency: CryptoCurrency, + addressModel: AddressModel, + ) { + viewModelScope.launch(dispatchers.main) { + router.openUrl( + url = getExploreUrlUseCase( + userWalletId = userWalletId, + currency = currency, + addressType = AddressType.valueOf(addressModel.type.name), + ), + ) + + stateHolder.update( + CloseBottomSheetTransformer(userWalletId = userWalletId), + ) + } + } + + private fun showErrorIfDemoModeOrElse(action: () -> Unit) { + val cardId = getSelectedWalletSyncUseCase.unwrap()?.cardId ?: return + + if (isDemoCardUseCase(cardId = cardId)) { + stateHolder.update(CloseBottomSheetTransformer(userWalletId = stateHolder.getSelectedWalletId())) + + walletEventSender.send( + event = WalletEvent.ShowError( + text = resourceReference(id = R.string.alert_demo_feature_disabled), + ), + ) + } else { + action() + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt new file mode 100644 index 0000000000..cfa8be01ab --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/intents/WalletWarningsClickIntents.kt @@ -0,0 +1,287 @@ +package com.tangem.feature.wallet.presentation.wallet.viewmodels.intents + +import com.tangem.blockchain.blockchains.cardano.CardanoUtils +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.card.EllipticCurve +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.common.extensions.toMapKey +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.card.DerivePublicKeysUseCase +import com.tangem.domain.card.SetCardWasScannedUseCase +import com.tangem.domain.common.configs.CardConfig +import com.tangem.domain.common.util.derivationStyleProvider +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.redux.LegacyAction +import com.tangem.domain.redux.ReduxStateHolder +import com.tangem.domain.settings.NeverToSuggestRateAppUseCase +import com.tangem.domain.settings.RemindToRateAppLaterUseCase +import com.tangem.domain.tokens.FetchTokenListUseCase +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.wallets.models.UnlockWalletsError +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.wallets.usecase.UnlockWalletsUseCase +import com.tangem.domain.wallets.usecase.UpdateWalletUseCase +import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic +import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen +import com.tangem.feature.wallet.presentation.wallet.domain.ScanCardToUnlockWalletClickHandler +import com.tangem.feature.wallet.presentation.wallet.domain.ScanCardToUnlockWalletError +import com.tangem.feature.wallet.presentation.wallet.domain.unwrap +import com.tangem.feature.wallet.presentation.wallet.state.WalletAlertState +import com.tangem.feature.wallet.presentation.wallet.state.WalletEvent +import com.tangem.feature.wallet.presentation.wallet.state2.WalletStateHolderV2 +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.CloseBottomSheetTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.transformers.OpenBottomSheetTransformer +import com.tangem.feature.wallet.presentation.wallet.state2.utils.WalletEventSender +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal interface WalletWarningsClickIntents { + + fun onAddBackupCardClick() + + fun onCloseAlreadySignedHashesWarningClick() + + fun onGenerateMissedAddressesClick(missedAddressCurrencies: List) + + fun onOpenUnlockWalletsBottomSheetClick() + + fun onUnlockWalletClick() + + fun onScanToUnlockWalletClick() + + fun onLikeAppClick() + + fun onDislikeAppClick() + + fun onCloseRateAppWarningClick() +} + +@Suppress("LongParameterList") +@ViewModelScoped +internal class WalletWarningsClickIntentsImplementer @Inject constructor( + private val stateHolder: WalletStateHolderV2, + private val walletEventSender: WalletEventSender, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val updateWalletUseCase: UpdateWalletUseCase, + private val unlockWalletsUseCase: UnlockWalletsUseCase, + private val derivePublicKeysUseCase: DerivePublicKeysUseCase, + private val scanCardToUnlockWalletClickHandler: ScanCardToUnlockWalletClickHandler, + private val fetchTokenListUseCase: FetchTokenListUseCase, + private val setCardWasScannedUseCase: SetCardWasScannedUseCase, + private val neverToSuggestRateAppUseCase: NeverToSuggestRateAppUseCase, + private val remindToRateAppLaterUseCase: RemindToRateAppLaterUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, + private val reduxStateHolder: ReduxStateHolder, + private val dispatchers: CoroutineDispatcherProvider, +) : BaseWalletClickIntents(), WalletWarningsClickIntents { + + override fun onAddBackupCardClick() { + analyticsEventHandler.send(MainScreen.NoticeBackupYourWalletTapped) + router.openOnboardingScreen() + } + + override fun onCloseAlreadySignedHashesWarningClick() { + val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return + + viewModelScope.launch(dispatchers.main) { + setCardWasScannedUseCase(cardId = userWallet.cardId) + } + } + + override fun onGenerateMissedAddressesClick(missedAddressCurrencies: List) { + val userWallet = getSelectedWalletSyncUseCase.unwrap() ?: return + + analyticsEventHandler.send(Basic.CardWasScanned(AnalyticsParam.ScannedFrom.Main)) + analyticsEventHandler.send(MainScreen.NoticeScanYourCardTapped) + + viewModelScope.launch(dispatchers.main) { + deriveMissingCurrencies( + scanResponse = userWallet.scanResponse, + currencyList = missedAddressCurrencies, + ) { scannedCardResponse -> + updateWalletUseCase( + userWalletId = userWallet.walletId, + update = { it.copy(scanResponse = scannedCardResponse) }, + ) + .onRight { fetchTokenListUseCase(userWalletId = it.walletId) } + } + } + } + + // TODO: [REDACTED_JIRA] + private fun deriveMissingCurrencies( + scanResponse: ScanResponse, + currencyList: List, + onSuccess: suspend (ScanResponse) -> Unit, + ) { + val config = CardConfig.createConfig(scanResponse.card) + val derivationDataList = currencyList.mapNotNull { + config.primaryCurve(blockchain = Blockchain.fromId(it.network.id.value))?.let { curve -> + getNewDerivations(curve, scanResponse, it) + } + } + + val derivations = buildMap> { + derivationDataList.forEach { + val current = this[it.derivations.first] + if (current != null) { + current.addAll(it.derivations.second) + current.distinct() + } else { + this[it.derivations.first] = it.derivations.second.toMutableList() + } + } + }.ifEmpty { return } + + viewModelScope.launch(dispatchers.io) { + derivePublicKeysUseCase(cardId = null, derivations = derivations) + .onRight { + val newDerivedKeys = it.entries + val oldDerivedKeys = scanResponse.derivedKeys + + val walletKeys = (newDerivedKeys.keys + oldDerivedKeys.keys).toSet() + + val updatedDerivedKeys = walletKeys.associateWith { walletKey -> + val oldDerivations = ExtendedPublicKeysMap(oldDerivedKeys[walletKey] ?: emptyMap()) + val newDerivations = newDerivedKeys[walletKey] ?: ExtendedPublicKeysMap(emptyMap()) + ExtendedPublicKeysMap(oldDerivations + newDerivations) + } + val updatedScanResponse = scanResponse.copy(derivedKeys = updatedDerivedKeys) + + onSuccess(updatedScanResponse) + } + } + } + + private fun getNewDerivations( + curve: EllipticCurve, + scanResponse: ScanResponse, + currency: CryptoCurrency, + ): DerivationData? { + val wallet = scanResponse.card.wallets.firstOrNull { it.curve == curve } ?: return null + + val blockchain = Blockchain.fromId(currency.network.id.value) + val supportedCurves = blockchain.getSupportedCurves() + val path = blockchain.derivationPath(scanResponse.derivationStyleProvider.getDerivationStyle()) + .takeIf { supportedCurves.contains(curve) } + + val customPath = currency.network.derivationPath.value?.let { + DerivationPath(it) + }.takeIf { supportedCurves.contains(curve) } + + val bothCandidates = listOfNotNull(path, customPath).distinct().toMutableList() + if (bothCandidates.isEmpty()) return null + + if (currency is CryptoCurrency.Coin && blockchain == Blockchain.Cardano) { + currency.network.derivationPath.value?.let { + bothCandidates.add(CardanoUtils.extendedDerivationPath(DerivationPath(it))) + } + } + + val mapKeyOfWalletPublicKey = wallet.publicKey.toMapKey() + val alreadyDerivedKeys: ExtendedPublicKeysMap = + scanResponse.derivedKeys[mapKeyOfWalletPublicKey] ?: ExtendedPublicKeysMap(emptyMap()) + val alreadyDerivedPaths = alreadyDerivedKeys.keys.toList() + + val toDerive = bothCandidates.filterNot { alreadyDerivedPaths.contains(it) } + if (toDerive.isEmpty()) return null + + return DerivationData(derivations = mapKeyOfWalletPublicKey to toDerive) + } + + class DerivationData(val derivations: Pair>) + + override fun onOpenUnlockWalletsBottomSheetClick() { + stateHolder.update( + OpenBottomSheetTransformer( + content = requireNotNull(stateHolder.getSelectedWallet().bottomSheetConfig).content, + userWalletId = stateHolder.getSelectedWalletId(), + onDismissBottomSheet = { + stateHolder.update( + CloseBottomSheetTransformer(userWalletId = stateHolder.getSelectedWalletId()), + ) + }, + ), + ) + } + + override fun onUnlockWalletClick() { + analyticsEventHandler.send(MainScreen.NoticeWalletLocked) + + viewModelScope.launch(dispatchers.main) { + unlockWalletsUseCase(throwIfNotAllWalletsUnlocked = true) + .onRight { stateHolder.update(CloseBottomSheetTransformer(stateHolder.getSelectedWalletId())) } + .onLeft(::handleUnlockWalletsError) + } + } + + private fun handleUnlockWalletsError(error: UnlockWalletsError) { + val event = when (error) { + is UnlockWalletsError.DataError, + is UnlockWalletsError.UnableToUnlockWallets, + -> WalletEvent.ShowToast(resourceReference(R.string.user_wallet_list_error_unable_to_unlock)) + is UnlockWalletsError.NoUserWalletSelected, + is UnlockWalletsError.NotAllUserWalletsUnlocked, + -> WalletEvent.ShowAlert(WalletAlertState.RescanWallets) + } + + walletEventSender.send(event) + } + + override fun onScanToUnlockWalletClick() { + analyticsEventHandler.send(event = MainScreen.WalletUnlockTapped) + + viewModelScope.launch(dispatchers.main) { + scanCardToUnlockWalletClickHandler(walletId = stateHolder.getSelectedWalletId()) + .onLeft { error -> + when (error) { + ScanCardToUnlockWalletError.WrongCardIsScanned -> { + walletEventSender.send( + event = WalletEvent.ShowAlert(WalletAlertState.WrongCardIsScanned), + ) + } + ScanCardToUnlockWalletError.ManyScanFails -> router.openScanFailedDialog() + } + } + } + } + + override fun onLikeAppClick() { + analyticsEventHandler.send(MainScreen.NoticeRateAppButton(AnalyticsParam.RateApp.Liked)) + + walletEventSender.send( + event = WalletEvent.RateApp( + onDismissClick = { + viewModelScope.launch(dispatchers.main) { + neverToSuggestRateAppUseCase() + } + }, + ), + ) + } + + override fun onDislikeAppClick() { + analyticsEventHandler.send(MainScreen.NoticeRateAppButton(AnalyticsParam.RateApp.Disliked)) + + viewModelScope.launch(dispatchers.main) { + neverToSuggestRateAppUseCase() + + reduxStateHolder.dispatch(LegacyAction.SendEmailRateCanBeBetter) + } + } + + override fun onCloseRateAppWarningClick() { + analyticsEventHandler.send(MainScreen.NoticeRateAppButton(AnalyticsParam.RateApp.Closed)) + + viewModelScope.launch(dispatchers.main) { + remindToRateAppLaterUseCase() + } + } +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index a3ed822b4a..edc76744e7 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -88,10 +88,10 @@ spr-client = "3.6.2" # endregion Other libraries # region Tangem -tangemBlockchainSdk = "release-app_5.3-406" +tangemBlockchainSdk = "release-app_5.4-412" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "release-app_5.3-313" -#tangemCardSdk = "0.0.1" # Keep it! - used for local builds +tangemCardSdk = "release-app_5.4-315" +#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ # endregion Tangem # region Tools diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyAmount.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyAmount.kt index aa4c58b0f3..4a877ccca3 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyAmount.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyAmount.kt @@ -13,4 +13,11 @@ data class ProxyAmount( val currencySymbol: String, var value: BigDecimal, val decimals: Int, -) \ No newline at end of file +) { + + companion object { + fun empty(): ProxyAmount { + return ProxyAmount("", BigDecimal.ZERO, 0) + } + } +} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt index be151b6dac..b8b3dfa295 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyFees.kt @@ -1,7 +1,14 @@ package com.tangem.lib.crypto.models -data class ProxyFees( - val minFee: ProxyFee, - val normalFee: ProxyFee, - val priorityFee: ProxyFee, -) \ No newline at end of file +sealed class ProxyFees { + + data class MultipleFees( + val minFee: ProxyFee, + val normalFee: ProxyFee, + val priorityFee: ProxyFee, + ) : ProxyFees() + + data class SingleFee( + val singleFee: ProxyFee, + ) : ProxyFees() +} \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/SwapTxData.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/SwapTxData.kt index 723169c990..4732f49fcb 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/SwapTxData.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/SwapTxData.kt @@ -13,6 +13,7 @@ import java.math.BigDecimal * @property amountToSend amount of tx * @property currencyToSend currency for tx */ +// TODO split to cex,dex data class SwapTxData( val networkId: String, val feeAmount: BigDecimal, diff --git a/settings.gradle.kts b/settings.gradle.kts index 59ac1ba906..3ab3775f15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -128,7 +128,7 @@ include(":domain:app-theme:models") include(":domain:balance-hiding") include(":domain:balance-hiding:models") include(":domain:transaction") - +include(":domain:analytics") // endregion Domain modules // region Data modules @@ -142,4 +142,5 @@ include(":data:source:preferences") include(":data:settings") include(":data:txhistory") include(":data:wallets") +include(":data:analytics") // endregion Data modules \ No newline at end of file