diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3cf379b69f..553088615c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.wallet" +} + configurations.all { exclude(group = "org.bouncycastle", module = "bcprov-jdk15to18") exclude(group = "com.github.komputing.kethereum") @@ -56,6 +60,7 @@ dependencies { implementation(projects.domain.transaction) implementation(projects.domain.analytics) implementation(projects.domain.visa) + implementation(projects.domain.onboarding) implementation(projects.common) implementation(projects.core.analytics) @@ -84,6 +89,7 @@ dependencies { implementation(projects.data.transaction) implementation(projects.data.visa) implementation(projects.data.promo) + implementation(projects.data.onboarding) /** Features */ implementation(projects.features.onboarding) @@ -112,6 +118,7 @@ dependencies { implementation(deps.androidx.core.ktx) implementation(deps.androidx.core.splashScreen) implementation(deps.androidx.appCompat) + implementation(deps.androidx.datastore) implementation(deps.androidx.fragment.ktx) implementation(deps.androidx.constraintLayout) implementation(deps.androidx.activity.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 99e803e45b..4b57ad94ed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> diff --git a/app/src/main/assets/testnet_tokens.json b/app/src/main/assets/testnet_tokens.json index b09a1ffa91..14327b84be 100644 --- a/app/src/main/assets/testnet_tokens.json +++ b/app/src/main/assets/testnet_tokens.json @@ -608,6 +608,36 @@ "networkId": "hedera-hashgraph/test" } ] + }, + { + "id": "aurora-near", + "name": "Aurora Testnet", + "symbol": "ETH", + "networks": [ + { + "networkId": "aurora/test" + } + ] + }, + { + "id": "areon-network", + "name": "Areon Network Testnet", + "symbol": "TAREA", + "networks": [ + { + "networkId": "areon-network/test" + } + ] + }, + { + "id": "pulsechain", + "name": "PulseChain Testnet v4", + "symbol": "tPLS", + "networks": [ + { + "networkId": "pulsechain/test" + } + ] } ] } diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 04b3f17541..116e6dcf12 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -36,6 +36,8 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.legacy.UserWalletsListManagerFeatureToggles +import com.tangem.domain.wallets.legacy.asLockable import com.tangem.feature.qrscanning.QrScanningRouter import com.tangem.features.managetokens.navigation.ManageTokensUi import com.tangem.features.send.api.navigation.SendRouter @@ -140,6 +142,12 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac @Inject lateinit var deepLinksRegistry: DeepLinksRegistry + @Inject + lateinit var userWalletsListManagerFeatureToggles: UserWalletsListManagerFeatureToggles + + @Inject + lateinit var generalUserWalletsListManager: UserWalletsListManager + internal val viewModel: MainViewModel by viewModels() private lateinit var appThemeModeFlow: SharedFlow @@ -438,7 +446,12 @@ class MainActivity : AppCompatActivity(), SnackbarHandler, ActivityResultCallbac } private fun navigateToInitialScreen(intentWhichStartedActivity: Intent?) { - val canSaveWallets = userWalletsListManager is BiometricUserWalletsListManager + val canSaveWallets = if (userWalletsListManagerFeatureToggles.isGeneralManagerEnabled) { + runCatching { userWalletsListManager.asLockable()?.isLockedSync } + .fold(onSuccess = { true }, onFailure = { false }) + } else { + userWalletsListManager is BiometricUserWalletsListManager + } val hasSavedWallets = userWalletsListManager.hasUserWallets if (canSaveWallets && hasSavedWallets) { diff --git a/app/src/main/java/com/tangem/tap/TapApplication.kt b/app/src/main/java/com/tangem/tap/TapApplication.kt index e4337c01f6..051cb803b8 100644 --- a/app/src/main/java/com/tangem/tap/TapApplication.kt +++ b/app/src/main/java/com/tangem/tap/TapApplication.kt @@ -30,11 +30,16 @@ import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.repository.AppThemeModeRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.common.LogConfig +import com.tangem.domain.feedback.FeedbackManagerFeatureToggles +import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.legacy.UserWalletsListManagerFeatureToggles import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.features.managetokens.featuretoggles.ManageTokensFeatureToggles import com.tangem.features.send.api.featuretoggles.SendFeatureToggles @@ -155,6 +160,24 @@ internal class TapApplication : Application(), ImageLoaderFactory { @Inject lateinit var accountCreator: AccountCreator + + @Inject + lateinit var userWalletsListManagerFeatureToggles: UserWalletsListManagerFeatureToggles + + @Inject + lateinit var generalUserWalletsListManager: UserWalletsListManager + + @Inject + lateinit var wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase + + @Inject + lateinit var saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase + + @Inject + lateinit var cardRepository: CardRepository + + @Inject + lateinit var feedbackManagerFeatureToggles: FeedbackManagerFeatureToggles // endregion Injected override fun onCreate() { @@ -183,8 +206,13 @@ internal class TapApplication : Application(), ImageLoaderFactory { // TODO: Try to performance and user experience. // [REDACTED_JIRA] runBlocking { - initUserWalletsListManager() featureTogglesManager.init() + + if (userWalletsListManagerFeatureToggles.isGeneralManagerEnabled) { + store.dispatch(GlobalAction.UpdateUserWalletsListManager(generalUserWalletsListManager)) + } else { + initUserWalletsListManager() + } } val configLoader = FeaturesLocalLoader(assetReader, MoshiConverter.sdkMoshi, BuildConfig.ENVIRONMENT) @@ -232,6 +260,12 @@ internal class TapApplication : Application(), ImageLoaderFactory { sendFeatureToggles = sendFeatureToggles, blockchainDataStorage = blockchainDataStorage, accountCreator = accountCreator, + userWalletsListManagerFeatureToggles = userWalletsListManagerFeatureToggles, + generalUserWalletsListManager = generalUserWalletsListManager, + wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, + saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, + cardRepository = cardRepository, + feedbackManagerFeatureToggles = feedbackManagerFeatureToggles, ), ), ) diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt b/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt index fa0d174bf9..019b203988 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Navigation.kt @@ -128,18 +128,13 @@ private fun fragmentFactory(screen: AppScreen): Fragment { AppScreen.OnboardingTwins -> OnboardingTwinsFragment() AppScreen.OnboardingOther -> OnboardingOtherCardsFragment() AppScreen.Wallet -> { - store.state.daggerGraphState - .get(getDependency = DaggerGraphState::walletRouter) - .getEntryFragment() + store.inject(getDependency = DaggerGraphState::walletRouter).getEntryFragment() } AppScreen.Send -> { - val featureToggles = store.state.daggerGraphState.get( - getDependency = DaggerGraphState::sendFeatureToggles, - ) + val featureToggles = store.inject(getDependency = DaggerGraphState::sendFeatureToggles) + if (featureToggles.isRedesignedSendEnabled) { - store.state.daggerGraphState - .get(getDependency = DaggerGraphState::sendRouter) - .getEntryFragment() + store.inject(getDependency = DaggerGraphState::sendRouter).getEntryFragment() } else { SendFragment() } @@ -154,15 +149,11 @@ private fun fragmentFactory(screen: AppScreen): Fragment { AppScreen.ManageTokens -> TokensListFragment() AppScreen.AddCustomToken -> AddCustomTokenFragment() AppScreen.WalletDetails -> { - store.state.daggerGraphState - .get(getDependency = DaggerGraphState::tokenDetailsRouter) - .getEntryFragment() + store.inject(getDependency = DaggerGraphState::tokenDetailsRouter).getEntryFragment() } AppScreen.WalletConnectSessions -> WalletConnectFragment() AppScreen.QrScanning -> { - store.state.daggerGraphState - .get(getDependency = DaggerGraphState::qrScanningRouter) - .getEntryFragment() + store.inject(getDependency = DaggerGraphState::qrScanningRouter).getEntryFragment() } AppScreen.ReferralProgram -> ReferralFragment() AppScreen.Swap -> SwapFragment() diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Store.kt b/app/src/main/java/com/tangem/tap/common/extensions/Store.kt index 79c87a7f28..d16d3488e2 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/Store.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/Store.kt @@ -7,6 +7,7 @@ import com.tangem.domain.wallets.models.UserWallet import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.TapError +import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope import com.tangem.tap.store import kotlinx.coroutines.Dispatchers @@ -87,4 +88,10 @@ fun Store<*>.dispatchOpenUrl(url: String) { fun Store<*>.dispatchShare(url: String) { dispatch(NavigationAction.Share(url)) +} + +inline fun Store.inject(getDependency: DaggerGraphState.() -> T?): T { + return requireNotNull(state.daggerGraphState.getDependency()) { + "${T::class.simpleName} isn't initialized " + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt b/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt index 9f6ba786f0..11c26259ee 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt @@ -37,7 +37,7 @@ suspend fun WalletManager.safeUpdate(isDemoCard: Boolean): Result = try } catch (exception: Exception) { Timber.e(exception) - val networkConnectionManager = store.state.daggerGraphState.get(DaggerGraphState::networkConnectionManager) + val networkConnectionManager = store.inject(DaggerGraphState::networkConnectionManager) if (!networkConnectionManager.isOnline) { Result.Failure(TapError.NoInternetConnection) } else { diff --git a/app/src/main/java/com/tangem/tap/common/feedback/DefaultFeedbackManagerFeatureToggles.kt b/app/src/main/java/com/tangem/tap/common/feedback/DefaultFeedbackManagerFeatureToggles.kt new file mode 100644 index 0000000000..85acfd9af6 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/feedback/DefaultFeedbackManagerFeatureToggles.kt @@ -0,0 +1,12 @@ +package com.tangem.tap.common.feedback + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager +import com.tangem.domain.feedback.FeedbackManagerFeatureToggles + +internal class DefaultFeedbackManagerFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : FeedbackManagerFeatureToggles { + + override val isLocalLogsEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "LOCAL_USER_LOGS_ENABLED") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt new file mode 100644 index 0000000000..0da020d6bf --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt @@ -0,0 +1,42 @@ +package com.tangem.tap.common.log + +import com.tangem.Log +import com.tangem.LogFormat +import com.tangem.TangemSdkLogger +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * CardSDK logger implementation + * + * @property levels logging levels + * @property messageFormatter message formatter + * @property settingsRepository settings repository + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +internal class TangemCardSDKLogger( + private val levels: List, + private val messageFormatter: LogFormat, + private val settingsRepository: SettingsRepository, + private val dispatchers: CoroutineDispatcherProvider, +) : TangemSdkLogger { + + private val scope = CoroutineScope(dispatchers.main) + private val mutex = Mutex() + + override fun log(message: () -> String, level: Log.Level) { + if (!levels.contains(level)) return + + scope.launch(dispatchers.main) { + mutex.withLock { + settingsRepository.updateAppLogs(message = messageFormatter.format(message, level)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt index c385b121ae..c8fa6db166 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt @@ -1,6 +1,7 @@ package com.tangem.tap.common.redux import com.tangem.domain.models.scan.ScanResponse +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.preferencesStorage import com.tangem.tap.proxy.redux.DaggerGraphState @@ -20,7 +21,7 @@ class AccessCodeRequestPolicyMiddleware { } private fun updateAccessCodeRequestPolicy(scanResponse: ScanResponse) { - store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( + store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( isBiometricsRequestPolicy = preferencesStorage.shouldSaveAccessCodes && scanResponse.card.isAccessCodeSet, ) } diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt index 666a16ab82..79d6ed3a69 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt @@ -123,7 +123,7 @@ private fun handleAction(action: Action, appState: () -> AppState?) { primaryRules = CardExchangeRules(cardProvider), ) // TODO: for refactoring (after remove old design refactor CurrencyExchangeManager and use 1 instance) - store.state.daggerGraphState.get(DaggerGraphState::appStateHolder).exchangeService = exchangeManager + store.inject(DaggerGraphState::appStateHolder).exchangeService = exchangeManager store.dispatchOnMain(GlobalAction.ExchangeManager.Init.Success(exchangeManager)) store.dispatchOnMain(GlobalAction.ExchangeManager.Update) } @@ -155,7 +155,7 @@ private fun handleAction(action: Action, appState: () -> AppState?) { } } is GlobalAction.UpdateUserWalletsListManager -> { - val walletManagersFacade = store.state.daggerGraphState.get(DaggerGraphState::walletManagersFacade) + val walletManagersFacade = store.inject(DaggerGraphState::walletManagersFacade) /* * If implementation of the UserWalletsListManager is changed, @@ -186,7 +186,7 @@ private fun handleAction(action: Action, appState: () -> AppState?) { private fun restoreAppCurrency() { scope.launch { - val currency = store.state.daggerGraphState.get(DaggerGraphState::appCurrencyRepository) + val currency = store.inject(DaggerGraphState::appCurrencyRepository) .getSelectedAppCurrency() .firstOrNull() ?: AppCurrency.Default diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt index bd03cf6cae..fbe3e38b89 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt @@ -3,11 +3,12 @@ package com.tangem.tap.common.redux.global import com.tangem.domain.redux.domainStore import com.tangem.domain.redux.global.DomainGlobalAction import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.home.redux.HomeAction import com.tangem.tap.features.onboarding.OnboardingManager -import com.tangem.tap.preferencesStorage import com.tangem.tap.proxy.AppStateHolder +import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.store import com.tangem.utils.extensions.replaceBy import org.rekotlin.Action @@ -20,8 +21,7 @@ fun globalReducer(action: Action, state: AppState, appStateHolder: AppStateHolde return when (action) { is GlobalAction.Onboarding.Start -> { - val usedCardsPrefStorage = preferencesStorage.usedCardsPrefStorage - val onboardingManager = OnboardingManager(action.scanResponse, usedCardsPrefStorage) + val onboardingManager = OnboardingManager(action.scanResponse) globalState.copy(onboardingState = OnboardingState(true, onboardingManager)) } is GlobalAction.Onboarding.StartForUnfinishedBackup -> { @@ -95,10 +95,17 @@ fun globalReducer(action: Action, state: AppState, appStateHolder: AppStateHolde ) } is GlobalAction.UpdateUserWalletsListManager -> { - appStateHolder.userWalletsListManager = action.manager - globalState.copy( - userWalletsListManager = action.manager, - ) + val featureToggles = store.inject(DaggerGraphState::userWalletsListManagerFeatureToggles) + + if (featureToggles.isGeneralManagerEnabled) { + val generalUserWalletsListManager = store.inject(DaggerGraphState::generalUserWalletsListManager) + + appStateHolder.userWalletsListManager = generalUserWalletsListManager + globalState.copy(userWalletsListManager = generalUserWalletsListManager) + } else { + appStateHolder.userWalletsListManager = action.manager + globalState.copy(userWalletsListManager = action.manager) + } } is GlobalAction.ChangeAppThemeMode -> globalState.copy( appThemeMode = action.appThemeMode, diff --git a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt b/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt new file mode 100644 index 0000000000..903d5542ea --- /dev/null +++ b/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt @@ -0,0 +1,34 @@ +package com.tangem.tap.data + +import com.tangem.blockchain.common.logging.BlockchainSDKLogger +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * BlockchainSDK logger implementation + * + * @property settingsRepository settings repository + * @property dispatchers coroutine dispatchers provider + * +[REDACTED_AUTHOR] + */ +internal class TangemBlockchainSDKLogger( + private val settingsRepository: SettingsRepository, + private val dispatchers: CoroutineDispatcherProvider, +) : BlockchainSDKLogger { + + private val scope = CoroutineScope(dispatchers.main) + private val mutex = Mutex() + + override fun log(level: BlockchainSDKLogger.Level, message: String) { + scope.launch(dispatchers.main) { + mutex.withLock { + settingsRepository.updateAppLogs(message) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/FeedbackManagerFeatureTogglesModule.kt b/app/src/main/java/com/tangem/tap/di/FeedbackManagerFeatureTogglesModule.kt new file mode 100644 index 0000000000..4202ba1af8 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/FeedbackManagerFeatureTogglesModule.kt @@ -0,0 +1,23 @@ +package com.tangem.tap.di + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager +import com.tangem.domain.feedback.FeedbackManagerFeatureToggles +import com.tangem.tap.common.feedback.DefaultFeedbackManagerFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object FeedbackManagerFeatureTogglesModule { + + @Provides + @Singleton + fun provideFeedbackManagerFeatureToggles( + featureTogglesManager: FeatureTogglesManager, + ): FeedbackManagerFeatureToggles { + return DefaultFeedbackManagerFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt b/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt new file mode 100644 index 0000000000..90906e322d --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/data/BlockchainSDKLoggerModule.kt @@ -0,0 +1,25 @@ +package com.tangem.tap.di.data + +import com.tangem.blockchain.common.logging.BlockchainSDKLogger +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.tap.data.TangemBlockchainSDKLogger +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object BlockchainSDKLoggerModule { + + @Provides + @Singleton + fun provideBlockchainSDKLogger( + settingsRepository: SettingsRepository, + dispatchers: CoroutineDispatcherProvider, + ): BlockchainSDKLogger { + return TangemBlockchainSDKLogger(settingsRepository, dispatchers) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt b/app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt new file mode 100644 index 0000000000..6da93efeb4 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/data/CardSDKLoggerModule.kt @@ -0,0 +1,45 @@ +package com.tangem.tap.di.data + +import com.tangem.Log +import com.tangem.LogFormat +import com.tangem.TangemSdkLogger +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.tap.common.log.TangemCardSDKLogger +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object CardSDKLoggerModule { + + @Provides + @Singleton + fun provideCardSDKLogger( + settingsRepository: SettingsRepository, + dispatchers: CoroutineDispatcherProvider, + ): TangemSdkLogger { + val logLevels = listOf( + Log.Level.ApduCommand, + Log.Level.Apdu, + Log.Level.Tlv, + Log.Level.Nfc, + Log.Level.Command, + Log.Level.Session, + Log.Level.View, + Log.Level.Network, + Log.Level.Error, + Log.Level.Biometric, + ) + + return TangemCardSDKLogger( + levels = logLevels, + messageFormatter = LogFormat.StairsFormatter(), + settingsRepository = settingsRepository, + dispatchers = dispatchers, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/AppCurrencyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AppCurrencyDomainModule.kt index ab7bc24782..f785627aa1 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AppCurrencyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AppCurrencyDomainModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.domain +import com.tangem.domain.appcurrency.FetchAppCurrenciesUseCase import com.tangem.domain.appcurrency.GetAvailableCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.SelectAppCurrencyUseCase @@ -8,6 +9,7 @@ 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) @@ -31,4 +33,10 @@ internal object AppCurrencyDomainModule { ): GetAvailableCurrenciesUseCase { return GetAvailableCurrenciesUseCase(appCurrencyRepository) } + + @Provides + @ViewModelScoped + fun provideFetchAppCurrenciesUseCase(appCurrencyRepository: AppCurrencyRepository): FetchAppCurrenciesUseCase { + return FetchAppCurrenciesUseCase(appCurrencyRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnboardingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnboardingDomainModule.kt new file mode 100644 index 0000000000..2ec4dabe50 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/OnboardingDomainModule.kt @@ -0,0 +1,31 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase +import com.tangem.domain.onboarding.repository.OnboardingRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object OnboardingDomainModule { + + @Provides + @Singleton + fun provideWasTwinsOnboardingShownUseCase( + onboardingRepository: OnboardingRepository, + ): WasTwinsOnboardingShownUseCase { + return WasTwinsOnboardingShownUseCase(onboardingRepository) + } + + @Provides + @Singleton + fun provideSaveTwinsOnboardingShownUseCase( + onboardingRepository: OnboardingRepository, + ): SaveTwinsOnboardingShownUseCase { + return SaveTwinsOnboardingShownUseCase(onboardingRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TxHistoryDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TxHistoryDomainModule.kt index 7ba9e05cff..7f0c9abe88 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TxHistoryDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TxHistoryDomainModule.kt @@ -3,6 +3,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.tokens.* import com.tangem.domain.txhistory.repository.TxHistoryRepository import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsUseCase import dagger.Module @@ -34,4 +35,10 @@ internal object TxHistoryDomainModule { ): GetExplorerTransactionUrlUseCase { return GetExplorerTransactionUrlUseCase(repository = txHistoryRepository) } + + @Provides + @ViewModelScoped + fun providesGetFixedTxHistoryItemsUseCase(txHistoryRepository: TxHistoryRepository): GetFixedTxHistoryItemsUseCase { + return GetFixedTxHistoryItemsUseCase(repository = txHistoryRepository) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt b/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt index 24db8bcbc8..03f3022a51 100644 --- a/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt @@ -12,6 +12,7 @@ import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.models.UserWallet import com.tangem.operations.attestation.Attestation +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.setContext import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.features.details.redux.walletconnect.WalletConnectAction @@ -44,8 +45,8 @@ class TapWalletManager( val walletManagerFactory: WalletManagerFactory by lazy { WalletManagerFactory( config = blockchainSdkConfig, - accountCreator = store.state.daggerGraphState.get(DaggerGraphState::accountCreator), - blockchainDataStorage = store.state.daggerGraphState.get(DaggerGraphState::blockchainDataStorage), + accountCreator = store.inject(DaggerGraphState::accountCreator), + blockchainDataStorage = store.inject(DaggerGraphState::blockchainDataStorage), ) } diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt index 7c21a8a2f2..1bac186d83 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt @@ -5,15 +5,14 @@ import com.tangem.common.core.TangemError import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.models.scan.ScanResponse +import com.tangem.tap.common.extensions.inject import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.store // TODO: Remove this object after feature toggle was removed and use ScanCardUseCase instead internal class DefaultScanCardProcessor : ScanCardProcessor { private val isNewCardScanningEnabled: Boolean - get() = store.state.daggerGraphState - .get(DaggerGraphState::customTokenFeatureToggles) - .isNewCardScanningEnabled + get() = store.inject(DaggerGraphState::customTokenFeatureToggles).isNewCardScanningEnabled override suspend fun scan( cardId: String?, diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index a1e3f77974..757e78cb34 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -13,12 +13,8 @@ import com.tangem.domain.common.TapWorkarounds.canSkipBackup import com.tangem.domain.common.extensions.withMainContext import com.tangem.domain.common.util.twinsIsTwinned import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.* import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor -import com.tangem.tap.common.extensions.addContext -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.setContext +import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.features.disclaimer.createDisclaimer import com.tangem.tap.features.disclaimer.redux.DisclaimerAction @@ -26,6 +22,10 @@ import com.tangem.tap.features.disclaimer.redux.DisclaimerCallback import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsAction import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsStep +import com.tangem.tap.proxy.redux.DaggerGraphState +import com.tangem.tap.scope +import com.tangem.tap.store +import com.tangem.tap.tangemSdkManager import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -165,7 +165,10 @@ internal object LegacyScanProcessor { navigateTo(appScreen) { onProgressStateChange(it) } } else { Analytics.setContext(scanResponse) - if (scanResponse.twinsIsTwinned() && !preferencesStorage.wasTwinsOnboardingShown()) { + + val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase).invokeSync() + + if (scanResponse.twinsIsTwinned() && !wasTwinsOnboardingShown) { onWalletNotCreated() store.dispatchOnMain(TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly(scanResponse))) navigateTo(AppScreen.OnboardingTwins) { onProgressStateChange(it) } diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt index 562ac25718..02e1701894 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt @@ -9,9 +9,9 @@ import com.tangem.core.navigation.NavigationAction import com.tangem.domain.card.ScanCardException import com.tangem.domain.models.scan.ScanResponse import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.scanCard.chains.* import com.tangem.tap.domain.scanCard.utils.ScanCardExceptionConverter -import com.tangem.tap.preferencesStorage import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.store import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE @@ -24,7 +24,7 @@ internal object UseCaseScanProcessor { cardId: String? = null, allowsRequestAccessCodeFromRepository: Boolean = false, ): CompletionResult { - val scanCardUseCase = store.state.daggerGraphState.get(DaggerGraphState::scanCardUseCase) + val scanCardUseCase = store.inject(DaggerGraphState::scanCardUseCase) return scanCardUseCase(cardId, allowsRequestAccessCodeFromRepository) .fold( ifLeft = { CompletionResult.Failure(scanCardExceptionConverter.convertBack(it)) }, @@ -45,14 +45,14 @@ internal object UseCaseScanProcessor { ) = progressScope(onProgressStateChange) { onScanStateChange(true) - val scanCardUseCase = store.state.daggerGraphState.get(DaggerGraphState::scanCardUseCase) + val scanCardUseCase = store.inject(DaggerGraphState::scanCardUseCase) val chains = buildList { add(ScanningFinishedChain { onScanStateChange(false) }) if (analyticsEvent != null) { add(AnalyticsChain(analyticsEvent)) } add(DisclaimerChain(store, disclaimerWillShow)) - add(CheckForOnboardingChain(store, store.state.globalState.tapWalletManager, preferencesStorage)) + add(CheckForOnboardingChain(store, store.state.globalState.tapWalletManager)) } scanCardUseCase(cardId, afterScanChains = chains) diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt index cb6471a4ca..3db1f7a79e 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt @@ -5,7 +5,6 @@ import arrow.core.left import arrow.core.right import com.tangem.core.analytics.Analytics import com.tangem.core.navigation.AppScreen -import com.tangem.data.source.preferences.PreferencesDataSource import com.tangem.domain.card.ScanCardException import com.tangem.domain.common.TapWorkarounds.canSkipBackup import com.tangem.domain.common.util.twinsIsTwinned @@ -13,6 +12,7 @@ import com.tangem.domain.core.chain.Chain import com.tangem.domain.models.scan.ScanResponse import com.tangem.tap.common.extensions.addContext import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.setContext import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction @@ -20,6 +20,7 @@ import com.tangem.tap.domain.TapWalletManager import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsAction import com.tangem.tap.features.onboarding.products.twins.redux.TwinCardsStep +import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.delay import org.rekotlin.Store @@ -32,14 +33,12 @@ import org.rekotlin.Store * * @param store the [Store] that holds the state of the app. * @param tapWalletManager manager responsible for handling operations related to the Wallet. - * @param preferencesStore data source for user preferences. * * @see Chain for more information about the Chain interface. */ class CheckForOnboardingChain( private val store: Store, private val tapWalletManager: TapWalletManager, - private val preferencesStore: PreferencesDataSource, ) : Chain { override suspend fun invoke( @@ -63,8 +62,12 @@ class CheckForOnboardingChain( } else -> { Analytics.setContext(previousChainResult) + + val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase) + .invokeSync() + // If twins was twinned previously but twins welcome not shown - if (previousChainResult.twinsIsTwinned() && !preferencesStore.wasTwinsOnboardingShown()) { + if (previousChainResult.twinsIsTwinned() && !wasTwinsOnboardingShown) { store.dispatchOnMain( TwinCardsAction.SetStepOfScreen(TwinCardsStep.WelcomeOnly(previousChainResult)), ) diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt index 0bc94eb628..d72eb92e23 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt @@ -32,9 +32,12 @@ import com.tangem.operations.backup.StartPrimaryCardLinkingTask import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask import com.tangem.operations.files.ReadFilesTask import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand +import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.TapSdkError -import com.tangem.tap.preferencesStorage +import com.tangem.tap.mainScope +import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope +import com.tangem.tap.store import kotlinx.coroutines.launch import kotlin.collections.set @@ -186,23 +189,28 @@ private class ScanWalletProcessor( session: CardSession, callback: (result: CompletionResult) -> Unit, ) { - val activationInProgress = preferencesStorage.usedCardsPrefStorage.isActivationInProgress(card.cardId) + mainScope.launch { + val activationInProgress = store.inject(DaggerGraphState::cardRepository) + .isActivationInProgress(card.cardId) - @Suppress("ComplexCondition") - if (card.backupStatus == CardDTO.BackupStatus.NoBackup && card.wallets.isNotEmpty() && activationInProgress) { - StartPrimaryCardLinkingTask().run(session) { linkingResult -> - when (linkingResult) { - is CompletionResult.Success -> { - primaryCard = linkingResult.data - deriveKeysIfNeeded(card, session, callback) - } - is CompletionResult.Failure -> { - deriveKeysIfNeeded(card, session, callback) + @Suppress("ComplexCondition") + if (card.backupStatus == CardDTO.BackupStatus.NoBackup && card.wallets.isNotEmpty() && + activationInProgress + ) { + StartPrimaryCardLinkingTask().run(session) { linkingResult -> + when (linkingResult) { + is CompletionResult.Success -> { + primaryCard = linkingResult.data + deriveKeysIfNeeded(card, session, callback) + } + is CompletionResult.Failure -> { + deriveKeysIfNeeded(card, session, callback) + } } } + } else { + deriveKeysIfNeeded(card, session, callback) } - } else { - deriveKeysIfNeeded(card, session, callback) } } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/DefaultUserWalletsListManagerFeatureToggles.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/DefaultUserWalletsListManagerFeatureToggles.kt new file mode 100644 index 0000000000..ca0f135159 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/DefaultUserWalletsListManagerFeatureToggles.kt @@ -0,0 +1,12 @@ +package com.tangem.tap.domain.userWalletList + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager +import com.tangem.domain.wallets.legacy.UserWalletsListManagerFeatureToggles + +internal class DefaultUserWalletsListManagerFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : UserWalletsListManagerFeatureToggles { + + override val isGeneralManagerEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(name = "GENERAL_USER_WALLETS_LIST_MANAGER_ENABLED") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerFeatureTogglesModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerFeatureTogglesModule.kt new file mode 100644 index 0000000000..f94041dc73 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerFeatureTogglesModule.kt @@ -0,0 +1,23 @@ +package com.tangem.tap.domain.userWalletList.di + +import com.tangem.core.featuretoggle.manager.FeatureTogglesManager +import com.tangem.domain.wallets.legacy.UserWalletsListManagerFeatureToggles +import com.tangem.tap.domain.userWalletList.DefaultUserWalletsListManagerFeatureToggles +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object UserWalletsListManagerFeatureTogglesModule { + + @Provides + @Singleton + fun provideUserWalletsListManagerFeatureToggles( + featureTogglesManager: FeatureTogglesManager, + ): UserWalletsListManagerFeatureToggles { + return DefaultUserWalletsListManagerFeatureToggles(featureTogglesManager) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt new file mode 100644 index 0000000000..1a162862e6 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerModule.kt @@ -0,0 +1,108 @@ +package com.tangem.tap.domain.userWalletList.di + +import android.content.Context +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.tangem.common.authentication.AuthenticatedStorage +import com.tangem.common.json.TangemSdkAdapter +import com.tangem.common.services.secure.SecureStorage +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.sdk.storage.AndroidSecureStorage +import com.tangem.sdk.storage.createEncryptedSharedPreferences +import com.tangem.tap.domain.userWalletList.implementation.BiometricUserWalletsListManager +import com.tangem.tap.domain.userWalletList.implementation.GeneralUserWalletsListManager +import com.tangem.tap.domain.userWalletList.implementation.RuntimeUserWalletsListManager +import com.tangem.tap.domain.userWalletList.repository.DelegatedKeystoreManager +import com.tangem.tap.domain.userWalletList.repository.UserWalletsKeysStoreDecorator +import com.tangem.tap.domain.userWalletList.repository.implementation.BiometricUserWalletsKeysRepository +import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultSelectedUserWalletRepository +import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUserWalletsPublicInformationRepository +import com.tangem.tap.domain.userWalletList.repository.implementation.DefaultUserWalletsSensitiveInformationRepository +import com.tangem.tap.domain.userWalletList.utils.json.* +import com.tangem.tap.tangemSdkManager +import com.tangem.utils.Provider +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object UserWalletsListManagerModule { + + @Provides + @Singleton + fun provideGeneralUserWalletsListManager( + @ApplicationContext applicationContext: Context, + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): UserWalletsListManager { + return GeneralUserWalletsListManager( + runtimeUserWalletsListManager = RuntimeUserWalletsListManager(), + biometricUserWalletsListManager = createBiometricUserWalletsListManager(applicationContext), + appPreferencesStore = appPreferencesStore, + dispatchers = dispatchers, + ) + } + + private fun createBiometricUserWalletsListManager(applicationContext: Context): UserWalletsListManager { + val moshi = Moshi.Builder() + .add(WalletDerivedKeysMapAdapter()) + .add(ScanResponseDerivedKeysMapAdapter()) + .add(ByteArrayKeyAdapter()) + .add(ExtendedPublicKeysMapAdapter()) + .add(CardBackupStatusAdapter()) + .add(DerivationPathAdapterWithMigration()) + .add(TangemSdkAdapter.DateAdapter()) + .add(TangemSdkAdapter.DerivationNodeAdapter()) + .add(TangemSdkAdapter.FirmwareVersionAdapter()) // For PrimaryCard model + .add(KotlinJsonAdapterFactory()) + .build() + + val secureStorage = AndroidSecureStorage( + preferences = SecureStorage.createEncryptedSharedPreferences( + context = applicationContext, + storageName = USER_WALLETS_STORAGE_NAME, + ), + ) + + val authenticatedStorage = AuthenticatedStorage( + secureStorage = UserWalletsKeysStoreDecorator( + featureStorage = secureStorage, + cardSdkStorageProvider = Provider { tangemSdkManager.secureStorage }, + ), + keystoreManager = DelegatedKeystoreManager( + keystoreManagerProvider = Provider { tangemSdkManager.keystoreManager }, + ), + ) + + val keysRepository = BiometricUserWalletsKeysRepository( + moshi = moshi, + secureStorage = secureStorage, + authenticatedStorage = authenticatedStorage, + ) + + val publicInformationRepository = DefaultUserWalletsPublicInformationRepository( + moshi = moshi, + secureStorage = secureStorage, + ) + + val sensitiveInformationRepository = DefaultUserWalletsSensitiveInformationRepository( + moshi = moshi, + secureStorage = secureStorage, + ) + + val selectedUserWalletRepository = DefaultSelectedUserWalletRepository(secureStorage = secureStorage) + + return BiometricUserWalletsListManager( + keysRepository = keysRepository, + publicInformationRepository = publicInformationRepository, + sensitiveInformationRepository = sensitiveInformationRepository, + selectedUserWalletRepository = selectedUserWalletRepository, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerProvider.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerProvider.kt index 025cb4df17..efb44252c2 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListManagerProvider.kt @@ -21,7 +21,7 @@ import com.tangem.tap.domain.userWalletList.utils.json.* import com.tangem.tap.tangemSdkManager import com.tangem.utils.Provider -private const val USER_WALLETS_STORAGE_NAME = "user_wallets_storage" +internal const val USER_WALLETS_STORAGE_NAME = "user_wallets_storage" fun UserWalletsListManager.Companion.provideBiometricImplementation( applicationContext: Context, diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt new file mode 100644 index 0000000000..cf7497ea51 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/implementation/GeneralUserWalletsListManager.kt @@ -0,0 +1,146 @@ +package com.tangem.tap.domain.userWalletList.implementation + +import com.tangem.common.CompletionResult +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.get +import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.models.UserWallet +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import timber.log.Timber + +/** + * General implementation of [UserWalletsListManager] that helps to switch between Runtime and Biometric + * implementations. + * + * @property runtimeUserWalletsListManager runtime user wallets list manager + * @property biometricUserWalletsListManager biometric user wallets list manager + * @property appPreferencesStore app preferences store + * @property dispatchers coroutine dispatcher provider + * +[REDACTED_AUTHOR] + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class GeneralUserWalletsListManager( + private val runtimeUserWalletsListManager: UserWalletsListManager, + private val biometricUserWalletsListManager: UserWalletsListManager, + private val appPreferencesStore: AppPreferencesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : UserWalletsListManager.Lockable { + + private val applicationScope = CoroutineScope(dispatchers.io) + private val implementation = MutableStateFlow(runtimeUserWalletsListManager) + + init { + subscribeOnCurrentManager() + } + + override val userWallets: Flow> + get() = implementation.flatMapLatest { it.userWallets } + + override val selectedUserWallet: Flow + get() = implementation.flatMapLatest { it.selectedUserWallet } + + override val selectedUserWalletSync: UserWallet? + get() = implementation.value.selectedUserWalletSync + + override val hasUserWallets: Boolean + get() = implementation.value.hasUserWallets + + override val walletsCount: Int + get() = implementation.value.walletsCount + + override val isLocked: Flow + get() = implementation.flatMapLatest { + if (it is UserWalletsListManager.Lockable) { + it.isLocked + } else { + error("RuntimeUserWalletsListManager is not lockable") + } + } + + override val isLockedSync: Boolean + get() { + val implementation = implementation.value + return if (implementation is UserWalletsListManager.Lockable) { + implementation.isLockedSync + } else { + error("RuntimeUserWalletsListManager is not lockable") + } + } + + override suspend fun select(userWalletId: UserWalletId): CompletionResult { + return implementation.value.select(userWalletId) + } + + override suspend fun save(userWallet: UserWallet, canOverride: Boolean): CompletionResult { + return implementation.value.save(userWallet, canOverride) + } + + override suspend fun update( + userWalletId: UserWalletId, + update: suspend (UserWallet) -> UserWallet, + ): CompletionResult { + return implementation.value.update(userWalletId, update) + } + + override suspend fun delete(userWalletIds: List): CompletionResult { + return implementation.value.delete(userWalletIds) + } + + override suspend fun clear(): CompletionResult { + return implementation.value.clear() + } + + override suspend fun get(userWalletId: UserWalletId): CompletionResult { + return implementation.value.get(userWalletId) + } + + override suspend fun unlock(throwIfNotAllWalletsUnlocked: Boolean): CompletionResult { + val implementation = implementation.value + return if (implementation is UserWalletsListManager.Lockable) { + implementation.unlock() + } else { + error("RuntimeUserWalletsListManager is not lockable") + } + } + + override fun lock() { + val implementation = implementation.value + return if (implementation is UserWalletsListManager.Lockable) { + implementation.lock() + } else { + error("RuntimeUserWalletsListManager is not lockable") + } + } + + private fun subscribeOnCurrentManager() { + appPreferencesStore.get(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false) + .distinctUntilChanged() + .onEach { shouldSaveUserWallets -> + val manager = if (shouldSaveUserWallets) { + biometricUserWalletsListManager.copyFrom(runtimeUserWalletsListManager) + } else { + runtimeUserWalletsListManager.copyFrom(biometricUserWalletsListManager) + } + + Timber.d("Switch to ${manager::class.java.simpleName}") + + implementation.value = manager + } + .flowOn(dispatchers.io) + .launchIn(applicationScope) + } + + /** Copy data from [old] manager and clean it */ + private suspend fun UserWalletsListManager.copyFrom(old: UserWalletsListManager): UserWalletsListManager { + old.selectedUserWalletSync?.let { this.save(it) } + old.clear() + + return this + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt index cbc281470e..a1c5cb5c0b 100644 --- a/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt +++ b/app/src/main/java/com/tangem/tap/domain/walletconnect/WalletConnectSdkHelper.kt @@ -20,6 +20,7 @@ import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic.TransactionSent.MemoType import com.tangem.domain.common.extensions.fromNetworkId import com.tangem.operations.sign.SignHashCommand +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.safeUpdate import com.tangem.tap.common.extensions.toFormattedString import com.tangem.tap.domain.walletconnect.BnbHelper.toWCBinanceTradeOrder @@ -59,7 +60,7 @@ class WalletConnectSdkHelper { val decimals = wallet.blockchain.decimals() val value = (transaction.value ?: "0").hexToBigDecimal() - ?.movePointLeft(decimals) ?: return null + .movePointLeft(decimals) ?: return null val gasLimit = getGasLimitFromTx(value, walletManager, transaction) @@ -118,8 +119,7 @@ class WalletConnectSdkHelper { private suspend fun getWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager? { val userWallet = userWalletsListManager.selectedUserWalletSync ?: return null - val walletManagerFacade = store.state.daggerGraphState - .get(DaggerGraphState::walletManagersFacade) + val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) return walletManagerFacade.getOrCreateWalletManager( userWalletId = userWallet.walletId, blockchain = blockchain, @@ -172,7 +172,7 @@ class WalletConnectSdkHelper { val result = (data.walletManager as TransactionSender).send( transactionData = data.transaction, signer = CommonSigner( - tangemSdk = store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository).sdk, + tangemSdk = store.inject(DaggerGraphState::cardSdkConfigRepository).sdk, cardId = cardId, ), ) diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/DefaultCustomTokenInteractor.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/DefaultCustomTokenInteractor.kt index f3f73a98d3..2de3e037af 100644 --- a/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/DefaultCustomTokenInteractor.kt +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/domain/DefaultCustomTokenInteractor.kt @@ -10,6 +10,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.tokens.AddCryptoCurrenciesUseCase import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.model.Currency import com.tangem.tap.features.customtoken.impl.domain.models.FoundToken import com.tangem.tap.proxy.redux.DaggerGraphState @@ -31,8 +32,8 @@ class DefaultCustomTokenInteractor( // TODO: Move to DI private val addCryptoCurrenciesUseCase by lazy(LazyThreadSafetyMode.NONE) { - val currenciesRepository = store.state.daggerGraphState.get(DaggerGraphState::currenciesRepository) - val networksRepository = store.state.daggerGraphState.get(DaggerGraphState::networksRepository) + val currenciesRepository = store.inject(DaggerGraphState::currenciesRepository) + val networksRepository = store.inject(DaggerGraphState::networksRepository) AddCryptoCurrenciesUseCase(currenciesRepository, networksRepository) } diff --git a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/viewmodels/AddCustomTokenViewModel.kt b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/viewmodels/AddCustomTokenViewModel.kt index 0825668edb..b15a5fdee5 100644 --- a/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/viewmodels/AddCustomTokenViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/customtoken/impl/presentation/viewmodels/AddCustomTokenViewModel.kt @@ -87,7 +87,7 @@ internal class AddCustomTokenViewModel @Inject constructor( currentCryptoCurrencies = getSelectedWalletSyncUseCase().fold( ifLeft = { emptyList() }, ifRight = { selectedWallet -> - getCurrenciesUseCase(selectedWallet.walletId).fold( + getCurrenciesUseCase.getSync(selectedWallet.walletId).fold( ifLeft = { emptyList() }, ifRight = { it }, ) diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt index 508f29e664..f429e3423f 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt @@ -24,10 +24,7 @@ import com.tangem.domain.wallets.legacy.isLockedSync import com.tangem.tap.* import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchDialogShow -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.onUserWalletSelected +import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.AppDialog import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction @@ -122,28 +119,24 @@ class DetailsMiddleware { doBeforeErase() tangemSdkManager.resetToFactorySettings(card.cardId, true) + .flatMap { userWalletsListManager.delete(listOfNotNull(userWalletId)) } .flatMap { tangemSdkManager.deleteSavedUserCodes(setOf(card.cardId)) } .doOnSuccess { Analytics.send(Settings.CardSettings.FactoryResetFinished()) val selectedUserWallet = userWalletsListManager.selectedUserWalletSync if (selectedUserWallet != null) { + store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet)) + store.onUserWalletSelected(selectedUserWallet) + } else { if (userWalletsListManager.isLockedSync) { - userWalletsListManager.delete(listOfNotNull(userWalletId)) store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Welcome)) } else { - store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet)) - delay(timeMillis = 500) - userWalletsListManager.delete(listOfNotNull(userWalletId)) - store.onUserWalletSelected(selectedUserWallet) + store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Home)) } - } else { - userWalletsListManager.delete(listOfNotNull(userWalletId)) - store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Home)) } } .doOnFailure { error -> - userWalletsListManager.delete(listOfNotNull(userWalletId)) if (error is TangemSdkError && error !is TangemSdkError.UserCancelled) { Analytics.send(Settings.CardSettings.FactoryResetFinished(error)) } @@ -268,7 +261,7 @@ class DetailsMiddleware { } private fun changeAppThemeMode(appThemeMode: AppThemeMode) { - val repository = store.state.daggerGraphState.get(DaggerGraphState::appThemeModeRepository) + val repository = store.inject(DaggerGraphState::appThemeModeRepository) scope.launch { repository.changeAppThemeMode(appThemeMode) @@ -278,7 +271,7 @@ class DetailsMiddleware { } private fun changeBalanceHiding(hideBalance: Boolean) { - val repository = store.state.daggerGraphState.get(DaggerGraphState::balanceHidingRepository) + val repository = store.inject(DaggerGraphState::balanceHidingRepository) scope.launch { val newState = repository.getBalanceHidingSettings().copy( @@ -292,7 +285,8 @@ class DetailsMiddleware { private fun toggleSaveWallets(state: DetailsState, enable: Boolean) = scope.launch { // Nothing to change - val walletsRepository = store.state.daggerGraphState.get(DaggerGraphState::walletsRepository) + val walletsRepository = store.inject(DaggerGraphState::walletsRepository) + if (walletsRepository.shouldSaveUserWalletsSync() == enable) { store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) return@launch @@ -360,6 +354,19 @@ class DetailsMiddleware { private suspend fun saveCurrentWallet( scanResponse: ScanResponse?, enableAccessCodesSaving: Boolean, + ): CompletionResult { + val featureToggles = store.inject(DaggerGraphState::userWalletsListManagerFeatureToggles) + + return if (featureToggles.isGeneralManagerEnabled) { + saveCurrentWalletByNewWay(scanResponse, enableAccessCodesSaving) + } else { + saveCurrentWalletByOldWay(scanResponse, enableAccessCodesSaving) + } + } + + private suspend fun saveCurrentWalletByOldWay( + scanResponse: ScanResponse?, + enableAccessCodesSaving: Boolean, ): CompletionResult { val userWallet = userWalletsListManager.selectedUserWalletSync ?: scanResponse?.let { UserWalletBuilder(it).build() } @@ -381,8 +388,26 @@ class DetailsMiddleware { Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.On)) preferencesStorage.shouldShowSaveUserWalletScreen = false - store.state.daggerGraphState.get(DaggerGraphState::walletsRepository) - .saveShouldSaveUserWallets(item = true) + store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = true) + } + .doOnFailure { error -> + Timber.e(error, "Unable to save user wallet") + } + } + + private suspend fun saveCurrentWalletByNewWay( + scanResponse: ScanResponse?, + enableAccessCodesSaving: Boolean, + ): CompletionResult { + store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = true) + + return if (enableAccessCodesSaving) { + saveAccessCodes(scanResponse) + } else { + CompletionResult.Success(Unit) + } + .doOnSuccess { + Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.On)) } .doOnFailure { error -> Timber.e(error, "Unable to save user wallet") @@ -390,13 +415,22 @@ class DetailsMiddleware { } private suspend fun deleteSavedWalletsAndAccessCodes(): CompletionResult { + val featureToggles = store.inject(DaggerGraphState::userWalletsListManagerFeatureToggles) + + return if (featureToggles.isGeneralManagerEnabled) { + deleteSavedWalletsAndAccessCodesByNewWay() + } else { + deleteSavedWalletsAndAccessCodesByOldWay() + } + } + + private suspend fun deleteSavedWalletsAndAccessCodesByOldWay(): CompletionResult { return userWalletsListManager.clear() .doOnSuccess { Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.Off)) deleteSavedAccessCodes() updateUserWalletsListManager(enableUserWalletsSaving = false) - store.state.daggerGraphState.get(DaggerGraphState::walletsRepository) - .saveShouldSaveUserWallets(item = false) + store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = false) store.dispatchWithMain(NavigationAction.PopBackTo(AppScreen.Home)) } @@ -405,12 +439,22 @@ class DetailsMiddleware { } } + private suspend fun deleteSavedWalletsAndAccessCodesByNewWay(): CompletionResult { + Analytics.send(Settings.AppSettings.SaveWalletSwitcherChanged(AnalyticsParam.OnOffState.Off)) + + deleteSavedAccessCodes() + store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = false) + + store.dispatchWithMain(NavigationAction.PopBackTo(AppScreen.Home)) + + return CompletionResult.Success(Unit) + } + private fun saveAccessCodes(scanResponse: ScanResponse?): CompletionResult { Analytics.send(Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.On)) preferencesStorage.shouldSaveAccessCodes = true - store.state.daggerGraphState - .get(DaggerGraphState::cardSdkConfigRepository) + store.inject(DaggerGraphState::cardSdkConfigRepository) .setAccessCodeRequestPolicy(isBiometricsRequestPolicy = scanResponse?.card?.isAccessCodeSet == true) return CompletionResult.Success(Unit) @@ -422,8 +466,7 @@ class DetailsMiddleware { Analytics.send(Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.Off)) preferencesStorage.shouldSaveAccessCodes = false - store.state.daggerGraphState - .get(DaggerGraphState::cardSdkConfigRepository) + store.inject(DaggerGraphState::cardSdkConfigRepository) .setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) } .doOnFailure { error -> @@ -482,7 +525,7 @@ class DetailsMiddleware { } private fun scanCard(state: DetailsState) = scope.launch { - store.state.daggerGraphState.get(DaggerGraphState::scanCardProcessor) + store.inject(DaggerGraphState::scanCardProcessor) .scan(allowsRequestAccessCodeFromRepository = true) .doOnSuccess { scanResponse -> // if we use biometric, scanResponse in GlobalState is null, and crashes NPE on twin cards @@ -512,7 +555,7 @@ class DetailsMiddleware { } private fun scanAndSaveUserWallet() = scope.launch(Dispatchers.IO) { - val cardSdkConfigRepository = store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository) + val cardSdkConfigRepository = store.inject(DaggerGraphState::cardSdkConfigRepository) val prevUseBiometricsForAccessCode = cardSdkConfigRepository.isBiometricsRequestPolicy() @@ -521,7 +564,7 @@ class DetailsMiddleware { isBiometricsRequestPolicy = preferencesStorage.shouldSaveAccessCodes, ) - store.state.daggerGraphState.get(DaggerGraphState::scanCardProcessor).scan( + store.inject(DaggerGraphState::scanCardProcessor).scan( analyticsEvent = Basic.CardWasScanned(CoreAnalyticsParam.ScannedFrom.MyWallets), onWalletNotCreated = { // No need to rollback policy, continue with the policy set before the card scan diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt index e6098d1dc9..e829d9c937 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt @@ -4,9 +4,11 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.common.CardTypesResolver import com.tangem.domain.common.util.cardTypesResolver import com.tangem.domain.models.scan.CardDTO +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState import com.tangem.tap.domain.extensions.signedHashesCount import com.tangem.tap.preferencesStorage +import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.store import com.tangem.tap.tangemSdkManager import kotlinx.coroutines.flow.firstOrNull @@ -76,12 +78,12 @@ private fun handlePrepareScreen(action: DetailsAction.PrepareScreen): DetailsSta saveAccessCodes = preferencesStorage.shouldSaveAccessCodes, selectedAppCurrency = store.state.globalState.appCurrency, selectedThemeMode = runBlocking { - store.state.daggerGraphState - .get { appThemeModeRepository }.getAppThemeMode().firstOrNull() ?: AppThemeMode.DEFAULT + store.inject(DaggerGraphState::appThemeModeRepository).getAppThemeMode().firstOrNull() + ?: AppThemeMode.DEFAULT }, isHidingEnabled = runBlocking { - store.state.daggerGraphState - .get { balanceHidingRepository }.getBalanceHidingSettings().isHidingEnabledInSettings + store.inject(DaggerGraphState::balanceHidingRepository) + .getBalanceHidingSettings().isHidingEnabledInSettings }, ), ) 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 60c2e73559..c6422a0706 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 @@ -20,6 +20,7 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.qrscanning.QrScanningRouter import com.tangem.feature.qrscanning.SourceType import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.walletconnect.BnbHelper @@ -47,9 +48,9 @@ import timber.log.Timber class WalletConnectMiddleware { private var walletConnectManager = WalletConnectManager() private val walletConnectInteractor: WalletConnectInteractor - get() = store.state.daggerGraphState.get(DaggerGraphState::walletConnectInteractor) + get() = store.inject(DaggerGraphState::walletConnectInteractor) private val walletConnectRepository: WalletConnectRepository - get() = store.state.daggerGraphState.get(DaggerGraphState::walletConnectRepository) + get() = store.inject(DaggerGraphState::walletConnectRepository) val walletConnectMiddleware: Middleware = { dispatch, state -> { next -> @@ -387,8 +388,7 @@ class WalletConnectMiddleware { } private suspend fun getWalletManagers(): List { - val walletManagerFacade = store.state.daggerGraphState - .get(DaggerGraphState::walletManagersFacade) + val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) val userWallet = userWalletsListManager.selectedUserWalletSync ?: return emptyList() return walletManagerFacade.getStoredWalletManagers(userWallet.walletId) @@ -407,7 +407,7 @@ class WalletConnectMiddleware { } private suspend fun getAvailableEvmBlockchains(userWalletId: UserWalletId): List { - val currenciesRepository = store.state.daggerGraphState.get(DaggerGraphState::currenciesRepository) + val currenciesRepository = store.inject(DaggerGraphState::currenciesRepository) return currenciesRepository.getMultiCurrencyWalletCurrenciesSync(userWalletId) .asSequence() @@ -496,8 +496,7 @@ class WalletConnectMiddleware { style = userWallet.scanResponse.derivationStyleProvider.getDerivationStyle(), )?.rawPath - val walletManagerFacade = store.state.daggerGraphState - .get(DaggerGraphState::walletManagersFacade) + val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) return walletManagerFacade.getOrCreateWalletManager( userWalletId = userWallet.walletId, @@ -511,8 +510,7 @@ class WalletConnectMiddleware { } private suspend fun getAccountsForWc(wcInteractor: WalletConnectInteractor, userWallet: UserWallet): List { - val walletManagerFacade = store.state.daggerGraphState - .get(DaggerGraphState::walletManagersFacade) + val walletManagerFacade = store.inject(DaggerGraphState::walletManagersFacade) return walletManagerFacade.getStoredWalletManagers(userWallet.walletId).mapNotNull { val wallet = it.wallet val chainId = wcInteractor.blockchainHelper.networkIdToChainIdOrNull( diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/details/DetailsScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/details/DetailsScreen.kt index 8a65d0e7bd..ec5a34ac4e 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/details/DetailsScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/details/DetailsScreen.kt @@ -262,7 +262,8 @@ private class DetailsScreenStateProvider : CollectionPreviewParameterProvider Unit, ) : SettingsItem( iconResId = R.drawable.ic_comment, - title = resourceReference(R.string.details_row_title_send_feedback), + title = resourceReference(R.string.details_row_title_contact_to_support), ) data class ReferralProgram( diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/details/DetailsViewModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/details/DetailsViewModel.kt index 3e12848581..5d4f2b20df 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/details/DetailsViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/details/DetailsViewModel.kt @@ -17,7 +17,6 @@ import com.tangem.tap.common.extensions.addContext import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.dispatchWithMain import com.tangem.tap.common.feedback.FeedbackEmail -import com.tangem.tap.common.feedback.SupportInfo import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.features.details.redux.DetailsAction @@ -89,8 +88,9 @@ internal class DetailsViewModel( SettingsItem.AppSettings(::navigateToAppSettings) .let(::add) - SettingsItem.Chat(::navigateToChat) - .let(::add) + // removed chat in task [REDACTED_TASK_KEY] + // SettingsItem.Chat(::navigateToChat) + // .let(::add) SettingsItem.SendFeedback(::sendFeedback) .let(::add) @@ -141,11 +141,6 @@ internal class DetailsViewModel( store.dispatchOnMain(GlobalAction.SendEmail(FeedbackEmail())) } - private fun navigateToChat() { - Analytics.send(Settings.ButtonChat()) - store.dispatchOnMain(GlobalAction.OpenChat(SupportInfo())) - } - private fun navigateToAppSettings() { Analytics.send(Settings.ButtonAppSettings()) store.dispatchOnMain(NavigationAction.NavigateTo(AppScreen.AppSettings)) diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt index 6f09751342..f92bc4be2f 100644 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt +++ b/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt @@ -8,8 +8,8 @@ import android.net.Uri interface Disclaimer { fun type(): DisclaimerType fun getUri(): Uri - fun accept() - fun isAccepted(): Boolean + suspend fun accept() + suspend fun isAccepted(): Boolean } abstract class BaseDisclaimer( @@ -18,32 +18,28 @@ abstract class BaseDisclaimer( val baseUrl = "https://tangem.com" - override fun accept() { - dataProvider.storage().accept(getPreferenceKey()) + override suspend fun accept() { + dataProvider.accept() } - override fun isAccepted(): Boolean = dataProvider.storage().isAccepted(getPreferenceKey()) - - protected open fun getPreferenceKey(): String = type().name + override suspend fun isAccepted(): Boolean = dataProvider.isAccepted() } class DummyDisclaimer : Disclaimer { override fun type(): DisclaimerType = DisclaimerType.Tangem override fun getUri(): Uri = Uri.parse("https://tangem.com/tangem_tos.html") - override fun accept() {} - override fun isAccepted(): Boolean = false + override suspend fun accept() {} + override suspend fun isAccepted(): Boolean = false } class TangemDisclaimer(dataProvider: DisclaimerDataProvider) : BaseDisclaimer(dataProvider) { override fun type(): DisclaimerType = DisclaimerType.Tangem override fun getUri(): Uri = Uri.parse("$baseUrl/tangem_tos.html") - override fun getPreferenceKey(): String = "tangem_tos_accepted" } class Start2CoinDisclaimer(dataProvider: DisclaimerDataProvider) : BaseDisclaimer(dataProvider) { override fun type(): DisclaimerType = DisclaimerType.Start2Coin override fun getUri(): Uri = Uri.parse("$baseUrl/" + filename(dataProvider.getLanguage(), getRegion())) - override fun getPreferenceKey(): String = "start2Coin_tos_accepted_${getRegion()}" @Suppress("ComplexMethod") private fun filename(languageCode: String, regionCode: String?): String { diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt index 9d3b4aeda6..ba3c82563d 100644 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt +++ b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt @@ -1,12 +1,11 @@ package com.tangem.tap.features.disclaimer -import com.tangem.data.source.preferences.storage.DisclaimerPrefStorage - /** [REDACTED_AUTHOR] */ interface DisclaimerDataProvider { fun getLanguage(): String fun getCardId(): String - fun storage(): DisclaimerPrefStorage + suspend fun accept() + suspend fun isAccepted(): Boolean } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt index b01b2b6f60..796452dcab 100644 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt +++ b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt @@ -2,8 +2,9 @@ package com.tangem.tap.features.disclaimer import com.tangem.domain.common.TapWorkarounds.isStart2Coin import com.tangem.domain.models.scan.CardDTO -import com.tangem.data.source.preferences.storage.DisclaimerPrefStorage -import com.tangem.tap.preferencesStorage +import com.tangem.tap.common.extensions.inject +import com.tangem.tap.proxy.redux.DaggerGraphState +import com.tangem.tap.store import java.util.Locale /** @@ -25,7 +26,7 @@ enum class DisclaimerType { } fun DisclaimerType.createDisclaimer(cardDTO: CardDTO): Disclaimer { - val dataProvider = provideDisclaimerDataProvider(cardDTO.cardId) + val dataProvider = provideDisclaimerDataProvider(cardDTO.cardId, this) return when (this) { DisclaimerType.Tangem -> TangemDisclaimer(dataProvider) DisclaimerType.Start2Coin -> Start2CoinDisclaimer(dataProvider) @@ -34,10 +35,24 @@ fun DisclaimerType.createDisclaimer(cardDTO: CardDTO): Disclaimer { fun CardDTO.createDisclaimer(): Disclaimer = DisclaimerType.get(this).createDisclaimer(this) -private fun provideDisclaimerDataProvider(cardId: String): DisclaimerDataProvider { +private fun provideDisclaimerDataProvider(cardId: String, disclaimerType: DisclaimerType): DisclaimerDataProvider { + val cardRepository = store.inject(DaggerGraphState::cardRepository) return object : DisclaimerDataProvider { override fun getLanguage(): String = Locale.getDefault().language override fun getCardId(): String = cardId - override fun storage(): DisclaimerPrefStorage = preferencesStorage.disclaimerPrefStorage + + override suspend fun accept() { + when (disclaimerType) { + DisclaimerType.Tangem -> cardRepository.acceptTangemTOS() + DisclaimerType.Start2Coin -> cardRepository.acceptStart2CoinTOS(cardId) + } + } + + override suspend fun isAccepted(): Boolean { + return when (disclaimerType) { + DisclaimerType.Tangem -> cardRepository.isTangemTOSAccepted() + DisclaimerType.Start2Coin -> cardRepository.isStart2CoinTOSAccepted(cardId) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/redux/DisclaimerMiddleware.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/redux/DisclaimerMiddleware.kt index e71184aff2..3e152187ea 100644 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/redux/DisclaimerMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/disclaimer/redux/DisclaimerMiddleware.kt @@ -3,7 +3,9 @@ package com.tangem.tap.features.disclaimer.redux import com.tangem.core.navigation.AppScreen import com.tangem.core.navigation.NavigationAction import com.tangem.tap.common.redux.AppState +import com.tangem.tap.mainScope import com.tangem.tap.store +import kotlinx.coroutines.launch import org.rekotlin.Action import org.rekotlin.Middleware @@ -26,9 +28,11 @@ private fun handleDisclaimerMiddleware(action: Action, appState: AppState) { store.dispatch(NavigationAction.NavigateTo(AppScreen.Disclaimer)) } is DisclaimerAction.AcceptDisclaimer -> { - state.disclaimer.accept() - store.dispatch(NavigationAction.PopBackTo()) - state.callback?.onAccept?.invoke() + mainScope.launch { + state.disclaimer.accept() + store.dispatch(NavigationAction.PopBackTo()) + state.callback?.onAccept?.invoke() + } } is DisclaimerAction.OnBackPressed -> { store.dispatch(NavigationAction.PopBackTo()) diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/ui/DisclaimerFragment.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/ui/DisclaimerFragment.kt index dc438d1872..d6767e13ac 100644 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/ui/DisclaimerFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/disclaimer/ui/DisclaimerFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.View import android.view.View.OVER_SCROLL_NEVER import android.webkit.WebView +import androidx.lifecycle.lifecycleScope import androidx.transition.TransitionInflater import by.kirich1409.viewbindingdelegate.viewBinding import com.tangem.core.navigation.AppScreen @@ -21,6 +22,7 @@ import com.tangem.tap.features.disclaimer.redux.DisclaimerState import com.tangem.tap.store import com.tangem.wallet.R import com.tangem.wallet.databinding.FragmentDisclaimerBinding +import kotlinx.coroutines.launch import org.rekotlin.StoreSubscriber class DisclaimerFragment : BaseFragment(R.layout.fragment_disclaimer), StoreSubscriber { @@ -92,10 +94,12 @@ class DisclaimerFragment : BaseFragment(R.layout.fragment_disclaimer), StoreSubs store.dispatch(DisclaimerAction.OnBackPressed) } - override fun newState(state: DisclaimerState) = with(binding) { - if (activity == null || view == null) return + override fun newState(state: DisclaimerState) { + return with(binding) { + if (activity == null || view == null) return - updateUiVisibility(state.disclaimer, state.progressState) + updateUiVisibility(state.disclaimer, state.progressState) + } } private fun updateUiVisibility(disclaimer: Disclaimer, progressState: ProgressState?) = with(binding) { @@ -112,7 +116,9 @@ class DisclaimerFragment : BaseFragment(R.layout.fragment_disclaimer), StoreSubs groupError.hide() groupLoading.hide() webView.show() - groupAccept.show(!disclaimer.isAccepted()) + lifecycleScope.launch { + groupAccept.show(!disclaimer.isAccepted()) + } } ProgressState.Error -> { root.beginDelayedTransition() diff --git a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt index d1210fe911..2bec7b7af6 100644 --- a/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/home/redux/HomeMiddleware.kt @@ -6,18 +6,15 @@ import com.tangem.common.doOnSuccess import com.tangem.common.extensions.guard import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.Basic import com.tangem.core.navigation.AppScreen import com.tangem.core.navigation.NavigationAction import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.userwallets.UserWalletBuilder -import com.tangem.core.analytics.models.Basic import com.tangem.tap.common.analytics.events.IntroductionProcess import com.tangem.tap.common.analytics.events.Shop import com.tangem.tap.common.entities.IndeterminateProgressButton -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.dispatchOpenUrl -import com.tangem.tap.common.extensions.eraseContext -import com.tangem.tap.common.extensions.onUserWalletSelected +import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.features.home.redux.HomeMiddleware.NEW_BUY_WALLET_URL @@ -79,11 +76,11 @@ private fun handleHomeAction(action: Action) { } private suspend fun readCard(analyticsEvent: AnalyticsEvent?) { - store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( + store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( isBiometricsRequestPolicy = preferencesStorage.shouldSaveAccessCodes, ) - store.state.daggerGraphState.get(DaggerGraphState::scanCardProcessor).scan( + store.inject(DaggerGraphState::scanCardProcessor).scan( analyticsEvent = analyticsEvent, onProgressStateChange = { showProgress -> if (showProgress) { diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 5725564ef4..dc5b58c12a 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope import com.tangem.core.navigation.AppScreen import com.tangem.core.navigation.NavigationAction import com.tangem.core.navigation.ReduxNavController +import com.tangem.domain.appcurrency.FetchAppCurrenciesUseCase import com.tangem.domain.balancehiding.BalanceHidingSettings import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.ListenToFlipsUseCase import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase import com.tangem.tap.features.main.model.MainScreenState +import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -20,6 +22,8 @@ internal class MainViewModel @Inject constructor( private val updateBalanceHidingSettingsUseCase: UpdateBalanceHidingSettingsUseCase, private val listenToFlipsUseCase: ListenToFlipsUseCase, private val reduxNavController: ReduxNavController, + private val fetchAppCurrenciesUseCase: FetchAppCurrenciesUseCase, + private val dispatchers: CoroutineDispatcherProvider, getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, ) : ViewModel(), MainIntents { @@ -33,11 +37,18 @@ internal class MainViewModel @Inject constructor( val state: StateFlow = stateHolder.stateFlow init { + updateAppCurrencies() observeFlips() displayBalancesHidingStatusToast() displayHiddenBalancesModalNotification() } + private fun updateAppCurrencies() { + viewModelScope.launch(dispatchers.main) { + fetchAppCurrenciesUseCase.invoke() + } + } + private fun observeFlips() { listenToFlipsUseCase().launchIn(viewModelScope) } 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 fa2b2a0832..fd59efff07 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 @@ -15,10 +15,7 @@ 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.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.onUserWalletSelected -import com.tangem.tap.common.extensions.removeContext -import com.tangem.tap.common.extensions.setContext +import com.tangem.tap.common.extensions.* import com.tangem.tap.features.saveWallet.redux.SaveWalletAction import com.tangem.tap.proxy.redux.DaggerGraphState import kotlinx.coroutines.delay @@ -29,26 +26,26 @@ import timber.log.Timber [REDACTED_AUTHOR] */ object OnboardingHelper { - fun isOnboardingCase(response: ScanResponse): Boolean { - val cardInfoStorage = preferencesStorage.usedCardsPrefStorage + suspend fun isOnboardingCase(response: ScanResponse): Boolean { + val onboardingManager = store.state.globalState.onboardingState.onboardingManager val cardId = response.card.cardId return when { response.cardTypesResolver.isTangemTwins() -> { if (!response.twinsIsTwinned()) { true } else { - cardInfoStorage.isActivationInProgress(cardId) + onboardingManager?.isActivationInProgress(cardId) ?: false } } response.cardTypesResolver.isWallet2() || response.cardTypesResolver.isShibaWallet() -> { val emptyWallets = response.card.wallets.isEmpty() - val activationInProgress = cardInfoStorage.isActivationInProgress(cardId) + val activationInProgress = onboardingManager?.isActivationInProgress(cardId) val backupNotActive = response.card.backupStatus?.isActive != true - emptyWallets || activationInProgress || backupNotActive + emptyWallets || activationInProgress == true || backupNotActive } - response.card.wallets.isNotEmpty() -> cardInfoStorage.isActivationInProgress(cardId) + response.card.wallets.isNotEmpty() -> onboardingManager?.isActivationInProgress(cardId) ?: false else -> true } } @@ -80,7 +77,7 @@ object OnboardingHelper { scope.launch { when { // When should save user wallets, then save card without navigate to save wallet screen - store.state.daggerGraphState.get(DaggerGraphState::walletsRepository).shouldSaveUserWalletsSync() -> { + store.inject(DaggerGraphState::walletsRepository).shouldSaveUserWalletsSync() -> { proceedWithScanResponse(scanResponse, backupCardsIds) store.dispatchOnMain( diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingManager.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingManager.kt index 467d4d73a5..d37b7153d1 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingManager.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingManager.kt @@ -5,11 +5,12 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.WalletManager import com.tangem.common.extensions.isZero import com.tangem.common.services.Result -import com.tangem.data.source.preferences.storage.UsedCardsPrefStorage +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.operations.attestation.CardVerifyAndGetInfo import com.tangem.operations.attestation.OnlineCardVerifier import com.tangem.tap.common.entities.ProgressState +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.isPositive import com.tangem.tap.common.extensions.safeUpdate import com.tangem.tap.domain.TapError @@ -17,17 +18,17 @@ import com.tangem.tap.domain.extensions.getOrLoadCardArtworkUrl import com.tangem.tap.domain.model.Currency import com.tangem.tap.domain.model.hasPendingTransactions import com.tangem.tap.features.demo.isDemoCard +import com.tangem.tap.proxy.redux.DaggerGraphState +import com.tangem.tap.store import timber.log.Timber import java.math.BigDecimal /** [REDACTED_AUTHOR] */ -class OnboardingManager( - var scanResponse: ScanResponse, - private val usedCardsPrefStorage: UsedCardsPrefStorage, -) { +class OnboardingManager(var scanResponse: ScanResponse) { + private val cardRepository: CardRepository = store.inject(DaggerGraphState::cardRepository) private var cardInfo: Result? = null suspend fun loadArtworkUrl(): String { @@ -76,17 +77,23 @@ class OnboardingManager( ) } - fun activationStarted(cardId: String) { - usedCardsPrefStorage.activationStarted(cardId) + suspend fun startActivation(cardId: String) { + cardRepository.startCardActivation(cardId) } - fun activationFinished(cardId: String) { - usedCardsPrefStorage.activationFinished(cardId) + suspend fun finishActivation(cardId: String) { + cardRepository.finishCardActivation(cardId) } - fun isActivationStarted(cardId: String): Boolean { - return usedCardsPrefStorage.isActivationStarted(cardId) + suspend fun finishActivation(cardIds: List) { + cardRepository.finishCardsActivation(cardIds) } + + suspend fun isActivationStarted(cardId: String): Boolean = cardRepository.isActivationStarted(cardId) + + suspend fun isActivationFinished(cardId: String): Boolean = cardRepository.isActivationFinished(cardId) + + suspend fun isActivationInProgress(cardId: String): Boolean = cardRepository.isActivationInProgress(cardId) } data class OnboardingWalletBalance( diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingMenuProvider.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingMenuProvider.kt index 4af057eb58..b979fd2d8c 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingMenuProvider.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingMenuProvider.kt @@ -22,7 +22,8 @@ class OnboardingMenuProvider : MenuProvider { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.menu_item_chat_support -> { Analytics.send(Onboarding.ButtonChat()) - store.dispatch(GlobalAction.OpenChat(SupportInfo())) + // changed on email support [REDACTED_TASK_KEY] + store.dispatch(GlobalAction.SendEmail(SupportInfo())) true } else -> false 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 cf01d56658..114dedc7df 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 @@ -20,6 +20,7 @@ import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE import com.tangem.tap.features.onboarding.OnboardingDialog import com.tangem.tap.features.onboarding.OnboardingHelper +import com.tangem.tap.mainScope import com.tangem.tap.scope import com.tangem.tap.store import com.tangem.tap.tangemSdkManager @@ -57,8 +58,10 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch when (action) { is OnboardingNoteAction.Init -> { - if (!onboardingManager.isActivationStarted(card.cardId)) { - Analytics.send(Onboarding.Started()) + scope.launch { + if (!onboardingManager.isActivationStarted(card.cardId)) { + Analytics.send(Onboarding.Started()) + } } } is OnboardingNoteAction.LoadCardArtwork -> { @@ -86,8 +89,10 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch } OnboardingNoteStep.Done -> { Analytics.send(Onboarding.Finished()) - onboardingManager.activationFinished(card.cardId) - postUi(DELAY_SDK_DIALOG_CLOSE) { store.dispatch(OnboardingNoteAction.Confetti.Show) } + mainScope.launch { + onboardingManager.finishActivation(card.cardId) + postUi(DELAY_SDK_DIALOG_CLOSE) { store.dispatch(OnboardingNoteAction.Confetti.Show) } + } } else -> Unit } @@ -101,7 +106,7 @@ private fun handleNoteAction(appState: () -> AppState?, action: Action, dispatch Analytics.send(Onboarding.CreateWallet.WalletCreatedSuccessfully()) val updatedResponse = scanResponse.copy(card = result.data.card) onboardingManager.scanResponse = updatedResponse - onboardingManager.activationStarted(updatedResponse.card.cardId) + onboardingManager.startActivation(updatedResponse.card.cardId) store.dispatch(OnboardingNoteAction.SetStepOfScreen(OnboardingNoteStep.TopUpWallet)) } is CompletionResult.Failure -> Unit diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/otherCards/redux/OnboardingOtherCardsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/otherCards/redux/OnboardingOtherCardsMiddleware.kt index 243319a1c0..d5e8144485 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/otherCards/redux/OnboardingOtherCardsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/otherCards/redux/OnboardingOtherCardsMiddleware.kt @@ -11,6 +11,7 @@ import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.features.onboarding.OnboardingDialog import com.tangem.tap.features.onboarding.OnboardingHelper +import com.tangem.tap.mainScope import com.tangem.tap.scope import com.tangem.tap.store import com.tangem.tap.tangemSdkManager @@ -43,8 +44,10 @@ private fun handleOtherCardsAction(action: Action) { when (action) { is OnboardingOtherCardsAction.Init -> { - if (!onboardingManager.isActivationStarted(card.cardId)) { - Analytics.send(Onboarding.Started()) + scope.launch { + if (!onboardingManager.isActivationStarted(card.cardId)) { + Analytics.send(Onboarding.Started()) + } } } is OnboardingOtherCardsAction.LoadCardArtwork -> { @@ -67,8 +70,10 @@ private fun handleOtherCardsAction(action: Action) { } OnboardingOtherCardsStep.Done -> { Analytics.send(Onboarding.Finished()) - onboardingManager.activationFinished(card.cardId) - postUi(200) { store.dispatch(OnboardingOtherCardsAction.Confetti.Show) } + mainScope.launch { + onboardingManager.finishActivation(card.cardId) + postUi(200) { store.dispatch(OnboardingOtherCardsAction.Confetti.Show) } + } } else -> Unit } @@ -88,7 +93,7 @@ private fun handleOtherCardsAction(action: Action) { ) val updatedCard = updatedResponse.card onboardingManager.scanResponse = updatedResponse - onboardingManager.activationStarted(updatedCard.cardId) + onboardingManager.startActivation(updatedCard.cardId) delay(DELAY_SDK_DIALOG_CLOSE) store.dispatch(OnboardingOtherCardsAction.SetStepOfScreen(OnboardingOtherCardsStep.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 fafb9a473f..90f14188f3 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 @@ -26,7 +26,7 @@ import com.tangem.tap.domain.twins.TwinCardsManager import com.tangem.tap.features.home.RUSSIA_COUNTRY_CODE import com.tangem.tap.features.onboarding.OnboardingDialog import com.tangem.tap.features.onboarding.OnboardingHelper -import com.tangem.tap.preferencesStorage +import com.tangem.tap.mainScope import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope import com.tangem.tap.store @@ -75,44 +75,61 @@ private fun handle(action: Action, dispatch: DispatchFunction) { fun startCardActivation(cardId: String) { if (twinCardsState.mode == CreateTwinWalletMode.CreateWallet) { - onboardingManager?.activationStarted(cardId) + scope.launch { + onboardingManager?.startActivation(cardId) + } } } fun finishCardActivation() { if (twinCardsState.mode == CreateTwinWalletMode.CreateWallet) { Analytics.send(Onboarding.Finished()) - onboardingManager?.activationFinished(getScanResponse().card.cardId) - twinCardsState.pairCardId?.let { onboardingManager?.activationFinished(it) } + + val cardIds = listOfNotNull(getScanResponse().card.cardId, twinCardsState.pairCardId) + + if (cardIds.isNotEmpty()) { + mainScope.launch { + onboardingManager?.finishActivation(cardIds) + } + } } } when (action) { is TwinCardsAction.Init -> { - if (twinCardsState.currentStep is TwinCardsStep.WelcomeOnly) return + mainScope.launch { + if (twinCardsState.currentStep is TwinCardsStep.WelcomeOnly) return@launch - val scanResponse = getScanResponse() - onboardingManager?.apply { - if (!isActivationStarted(scanResponse.card.cardId)) { - Analytics.send(Onboarding.Started()) - } - } - - when (twinCardsState.mode) { - CreateTwinWalletMode.CreateWallet -> { - if (preferencesStorage.wasTwinsOnboardingShown()) { - val step = when { - !scanResponse.twinsIsTwinned() -> TwinCardsStep.CreateFirstWallet - twinCardsState.walletBalance.balanceIsToppedUp() -> TwinCardsStep.Done - else -> TwinCardsStep.TopUpWallet - } - store.dispatch(TwinCardsAction.SetStepOfScreen(step)) - } else { - store.dispatch(TwinCardsAction.SetStepOfScreen(TwinCardsStep.Welcome)) + val scanResponse = getScanResponse() + onboardingManager?.apply { + if (!isActivationStarted(scanResponse.card.cardId)) { + Analytics.send(Onboarding.Started()) } } - CreateTwinWalletMode.RecreateWallet -> { - store.dispatch(TwinCardsAction.SetStepOfScreen(TwinCardsStep.Warning)) + + when (twinCardsState.mode) { + CreateTwinWalletMode.CreateWallet -> { + mainScope.launch { + val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase) + .invokeSync() + + val dispatchAction = if (wasTwinsOnboardingShown) { + val step = when { + !scanResponse.twinsIsTwinned() -> TwinCardsStep.CreateFirstWallet + twinCardsState.walletBalance.balanceIsToppedUp() -> TwinCardsStep.Done + else -> TwinCardsStep.TopUpWallet + } + TwinCardsAction.SetStepOfScreen(step) + } else { + TwinCardsAction.SetStepOfScreen(TwinCardsStep.Welcome) + } + + store.dispatch(dispatchAction) + } + } + CreateTwinWalletMode.RecreateWallet -> { + store.dispatch(TwinCardsAction.SetStepOfScreen(TwinCardsStep.Warning)) + } } } } @@ -120,7 +137,10 @@ private fun handle(action: Action, dispatch: DispatchFunction) { when (action.step) { is TwinCardsStep.WelcomeOnly, TwinCardsStep.Welcome -> { Analytics.send(Onboarding.Twins.ScreenOpened()) - preferencesStorage.saveTwinsOnboardingShown() + + scope.launch { + store.inject(DaggerGraphState::saveTwinsOnboardingShownUseCase).invoke() + } } is TwinCardsStep.CreateFirstWallet -> { Analytics.send(Onboarding.CreateWallet.ScreenOpened()) @@ -295,7 +315,7 @@ private fun handle(action: Action, dispatch: DispatchFunction) { } CreateTwinWalletMode.RecreateWallet -> { scope.launch { - val walletsRepository = store.state.daggerGraphState.get(DaggerGraphState::walletsRepository) + val walletsRepository = store.inject(DaggerGraphState::walletsRepository) if (walletsRepository.shouldSaveUserWalletsSync()) { OnboardingHelper.trySaveWalletAndNavigateToWalletScreen(scanResponse) diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt index 2a4ca101c4..460734903f 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/redux/OnboardingWalletMiddleware.kt @@ -27,6 +27,8 @@ import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Onboarding import com.tangem.tap.common.extensions.dispatchDialogShow import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.extensions.dispatchWithMain +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.tasks.product.CreateProductWalletTaskResponse @@ -72,8 +74,10 @@ private fun handleWalletAction(action: Action) { when (action) { OnboardingWalletAction.Init -> { ifNotNull(onboardingManager, card) { manager, notNullCard -> - if (!manager.isActivationStarted(notNullCard.cardId)) { - Analytics.send(Onboarding.Started()) + mainScope.launch { + if (!manager.isActivationStarted(notNullCard.cardId)) { + Analytics.send(Onboarding.Started()) + } } } when { @@ -144,8 +148,10 @@ private fun handleWalletAction(action: Action) { onboardingManager.scanResponse = updatedResponse store.dispatch(GlobalAction.Onboarding.ShouldResetCardOnCreate(false)) - startCardActivation(updatedResponse) - store.dispatch(OnboardingWalletAction.ResumeBackup) + mainScope.launch { + onboardingManager.startActivation(updatedResponse.card.cardId) + store.dispatchWithMain(OnboardingWalletAction.ResumeBackup) + } } is CompletionResult.Failure -> { if (result.error is TangemSdkError.WalletAlreadyCreated) { @@ -398,8 +404,7 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) store.dispatchOnMain(BackupAction.AddBackupCard.ChangeButtonLoading(true)) backupService.addBackupCard { result -> backupService.skipCompatibilityChecks = false - store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository).sdk - .config.filter.cardIdFilter = null + store.inject(DaggerGraphState::cardSdkConfigRepository).sdk.config.filter.cardIdFilter = null store.dispatchOnMain(BackupAction.AddBackupCard.ChangeButtonLoading(false)) when (result) { is CompletionResult.Success -> { @@ -493,11 +498,13 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) backupService.discardSavedBackup() } is BackupAction.DiscardSavedBackup -> { - backupService.primaryCardId?.let { - Analytics.send(Onboarding.Finished()) - finishCardsActivationForDiscardedUnfinishedBackup(it) + mainScope.launch { + backupService.primaryCardId?.let { + Analytics.send(Onboarding.Finished()) + store.state.globalState.onboardingState.onboardingManager?.finishActivation(it) + } + backupService.discardSavedBackup() } - backupService.discardSavedBackup() } is BackupAction.CheckForUnfinishedBackup -> { if (backupService.hasIncompletedBackup) { @@ -514,17 +521,21 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) } is BackupAction.SkipBackup -> { Analytics.send(Onboarding.Backup.Skipped()) - Analytics.send(Onboarding.Finished()) - finishCardActivation(gatherCardIds(backupState, card)) + + scope.launch { + store.state.globalState.onboardingState.onboardingManager?.finishActivation( + cardIds = gatherCardIds(backupState, card), + ) + } } is BackupAction.FinishBackup -> { - if (action.withAnalytics) { - Analytics.send(Onboarding.Backup.Finished(backupState.backupCardsNumber)) - } + scope.launch { + if (action.withAnalytics) { + Analytics.send(Onboarding.Backup.Finished(backupState.backupCardsNumber)) + } - if (scanResponse != null) { - scope.launch { + if (scanResponse != null) { val userWallet = UserWalletBuilder(scanResponse) .backupCardsIds(backupState.backupCardIds.toSet()) .build() @@ -546,16 +557,22 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) ) store.dispatchOnMain(GlobalAction.UpdateUserWalletsListManager(userWalletsListManager)) } + + val notActivatedCardIds = gatherCardIds(backupState, card).mapNotNull { + if (store.state.globalState.onboardingState.onboardingManager?.isActivationFinished(it) == true) { + null + } else { + it + } + } + + // All cardIds may already be activated if the backup was skipped before. + if (notActivatedCardIds.isEmpty()) return@launch + + Analytics.send(Onboarding.Finished()) + + store.state.globalState.onboardingState.onboardingManager?.finishActivation(notActivatedCardIds) } - - val notActivatedCardIds = gatherCardIds(backupState, card) - .mapNotNull { if (cardActivationIsFinished(it)) null else it } - - // All cardIds may already be activated if the backup was skipped before. - if (notActivatedCardIds.isEmpty()) return - - Analytics.send(Onboarding.Finished()) - finishCardActivation(notActivatedCardIds) } is BackupAction.ResetBackupCard -> { @@ -566,36 +583,12 @@ private fun handleBackupAction(appState: () -> AppState?, action: BackupAction) } } -private fun cardActivationIsFinished(cardId: String): Boolean { - return preferencesStorage.usedCardsPrefStorage.isActivationFinished(cardId) -} - -/** - * Standard Wallet cards start activation at OnboardingWalletAction.CreateWallet - */ -private fun startCardActivation(scanResponse: ScanResponse) { - preferencesStorage.usedCardsPrefStorage.activationStarted(scanResponse.card.cardId) -} - -/** - * Standard Wallet cards finish activation at BackupAction.SkipBackup and BackupAction.FinishBackup - */ -internal fun finishCardActivation(cardIds: List) { - cardIds.forEach { cardId -> - preferencesStorage.usedCardsPrefStorage.activationFinished(cardId) - } -} - internal fun gatherCardIds(backupState: BackupState, card: CardDTO?): List { return (listOf(backupState.primaryCardId, card?.cardId) + backupState.backupCardIds) .filterNotNull() .distinct() } -private fun finishCardsActivationForDiscardedUnfinishedBackup(cardId: String) { - preferencesStorage.usedCardsPrefStorage.activationFinished(cardId) -} - private fun handleOnBackPressed(state: OnboardingWalletState) { when (state.backupState.backupStep) { BackupStep.InitBackup, BackupStep.ScanOriginCard, BackupStep.AddBackupCards, BackupStep.EnterAccessCode, diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt index 9a08513cdb..dd07c89355 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/OnboardingWalletFragment.kt @@ -501,7 +501,8 @@ class OnboardingWalletFragment : private fun makeSeedPhraseRouter(): SeedPhraseRouter = SeedPhraseRouter( onBack = ::legacyOnBackHandler, onOpenChat = { - store.dispatch(GlobalAction.OpenChat(SupportInfo())) + // changed on email support [REDACTED_TASK_KEY] + store.dispatch(GlobalAction.SendEmail(SupportInfo())) }, onOpenUriClick = { uri -> store.dispatchOpenUrl(uri.toString()) diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt index bc213e1d41..cfabd25957 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/products/wallet/ui/dialogs/WalletActivationErrorDialog.kt @@ -17,8 +17,9 @@ object WalletActivationErrorDialog { setTitle(context.getString(R.string.onboarding_activation_error_title)) setMessage(context.getString(R.string.onboarding_activation_error_message)) setPositiveButton(R.string.common_ok) { _, _ -> dialog.onConfirm() } - setNegativeButton(R.string.chat_button_title) { _, _ -> - store.dispatch(GlobalAction.OpenChat(SupportInfo())) + setNegativeButton(R.string.common_support) { _, _ -> + // changed on email support [REDACTED_TASK_KEY] + store.dispatch(GlobalAction.SendEmail(SupportInfo())) } setOnDismissListener { store.dispatchDialogHide() } setCancelable(false) diff --git a/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletAction.kt b/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletAction.kt index 00a5c7413c..934f7e8880 100644 --- a/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletAction.kt +++ b/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletAction.kt @@ -16,6 +16,8 @@ internal sealed interface SaveWalletAction : Action { data class Error(val error: TangemError) : SaveWalletAction } + object AllowToUseBiometrics : SaveWalletAction + object Dismiss : SaveWalletAction object CloseError : SaveWalletAction diff --git a/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletMiddleware.kt b/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletMiddleware.kt index 9408cbcc9e..ddf1c22d74 100644 --- a/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletMiddleware.kt @@ -18,6 +18,7 @@ import com.tangem.tap.common.analytics.events.MainScreen import com.tangem.tap.common.analytics.events.Onboarding import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.dispatchWithMain +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.userWalletList.di.provideBiometricImplementation @@ -42,6 +43,7 @@ internal class SaveWalletMiddleware { private fun handleAction(action: SaveWalletAction, state: SaveWalletState) { when (action) { is SaveWalletAction.Save -> saveWalletIfBiometricsEnrolled(state) + is SaveWalletAction.AllowToUseBiometrics -> allowToUseBiometrics(state) is SaveWalletAction.EnrollBiometrics.Enroll -> enrollBiometrics() is SaveWalletAction.SaveWalletWasShown -> saveWalletWasShown() is SaveWalletAction.Dismiss -> dismiss(state) @@ -96,11 +98,16 @@ internal class SaveWalletMiddleware { .build() ?: return@launch - provideLockableUserWalletsListManagerIfNot() + val featureToggles = store.inject(DaggerGraphState::userWalletsListManagerFeatureToggles) + if (featureToggles.isGeneralManagerEnabled) { + store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = true) + } else { + provideLockableUserWalletsListManagerIfNot() + } val isFirstSavedWallet = !userWalletsListManager.hasUserWallets - saveAccessCodeIfNeeded(state.backupInfo?.accessCode, userWallet.cardsInWallet) + saveAccessCodeIfNeeded(accessCode = state.backupInfo?.accessCode, cardsInWallet = userWallet.cardsInWallet) .flatMap { // Save wallet only at first time (SaveWalletBottomSheet). // Otherwise (Example, add new wallet in Details) userWalletsListManager.wallets subscribers will @@ -116,16 +123,14 @@ internal class SaveWalletMiddleware { store.dispatchWithMain(SaveWalletAction.Save.Error(error)) } .doOnSuccess { - store.state.daggerGraphState.get(DaggerGraphState::walletsRepository) - .saveShouldSaveUserWallets(item = true) + store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = true) // Enable saving access codes only if this is the first time user save the wallet if (isFirstSavedWallet) { preferencesStorage.shouldSaveAccessCodes = true - store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository) - .setAccessCodeRequestPolicy( - isBiometricsRequestPolicy = userWallet.hasAccessCode, - ) + store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = userWallet.hasAccessCode, + ) } store.dispatchOnMain(SaveWalletAction.Save.Success) @@ -141,6 +146,46 @@ internal class SaveWalletMiddleware { } } + private fun allowToUseBiometrics(state: SaveWalletState) { + val scanResponse = state.backupInfo?.scanResponse + ?: store.state.globalState.scanResponse + ?: return + + if (state.backupInfo != null) { + // TODO: Remove after onboarding refactoring + Analytics.send(Onboarding.EnableBiometrics(AnalyticsParam.OnOffState.On)) + } else { + Analytics.send(MainScreen.EnableBiometrics(AnalyticsParam.OnOffState.On)) + } + + scope.launch { + val userWallet = userWalletsListManager.selectedUserWalletSync + ?: UserWalletBuilder(scanResponse) + .backupCardsIds(state.backupInfo?.backupCardsIds) + .build() + ?: return@launch + + store.inject(DaggerGraphState::walletsRepository).saveShouldSaveUserWallets(item = true) + + saveAccessCodeIfNeeded(accessCode = state.backupInfo?.accessCode, cardsInWallet = userWallet.cardsInWallet) + .flatMap { + userWalletsListManager.save(userWallet, canOverride = true) + } + .doOnFailure { error -> + store.dispatchWithMain(SaveWalletAction.Save.Error(error)) + } + .doOnSuccess { + preferencesStorage.shouldSaveAccessCodes = true + store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = userWallet.hasAccessCode, + ) + + store.dispatchOnMain(SaveWalletAction.Save.Success) + store.dispatchOnMain(NavigationAction.PopBackTo(AppScreen.Wallet)) + } + } + } + private suspend fun provideLockableUserWalletsListManagerIfNot() { if (store.state.globalState.userWalletsListManager?.isLockable == true) return diff --git a/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletReducer.kt b/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletReducer.kt index f2552462b6..b1c7269494 100644 --- a/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/saveWallet/redux/SaveWalletReducer.kt @@ -21,9 +21,9 @@ internal object SaveWalletReducer { backupCardsIds = action.backupCardsIds, ), ) - is SaveWalletAction.Save -> state.copy( - isSaveInProgress = true, - ) + is SaveWalletAction.Save, + is SaveWalletAction.AllowToUseBiometrics, + -> state.copy(isSaveInProgress = true) is SaveWalletAction.Save.Error -> state.copy( error = action.error, isSaveInProgress = false, diff --git a/app/src/main/java/com/tangem/tap/features/saveWallet/ui/SaveWalletViewModel.kt b/app/src/main/java/com/tangem/tap/features/saveWallet/ui/SaveWalletViewModel.kt index 4c56a3b9b4..99947493fc 100644 --- a/app/src/main/java/com/tangem/tap/features/saveWallet/ui/SaveWalletViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/saveWallet/ui/SaveWalletViewModel.kt @@ -3,6 +3,7 @@ package com.tangem.tap.features.saveWallet.ui import androidx.lifecycle.ViewModel import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.wallets.legacy.UserWalletsListManagerFeatureToggles import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.features.details.ui.cardsettings.TextReference @@ -19,6 +20,7 @@ import javax.inject.Inject @HiltViewModel internal class SaveWalletViewModel @Inject constructor( + private val userWalletsListManagerFeatureToggles: UserWalletsListManagerFeatureToggles, private val analyticsEventHandler: AnalyticsEventHandler, ) : ViewModel(), StoreSubscriber { private val stateInternal = MutableStateFlow(SaveWalletScreenState()) @@ -31,7 +33,12 @@ internal class SaveWalletViewModel @Inject constructor( fun saveWallet() { analyticsEventHandler.send(WalletScreenAnalyticsEvent.MainScreen.EnableBiometrics(AnalyticsParam.OnOffState.On)) - store.dispatch(SaveWalletAction.Save) + + if (userWalletsListManagerFeatureToggles.isGeneralManagerEnabled) { + store.dispatch(SaveWalletAction.AllowToUseBiometrics) + } else { + store.dispatch(SaveWalletAction.Save) + } } fun cancelOrClose() { diff --git a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/SendMiddleware.kt b/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/SendMiddleware.kt index 2c21ea333c..dd10c08389 100644 --- a/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/SendMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/send/redux/middlewares/SendMiddleware.kt @@ -24,10 +24,7 @@ import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.tokens.legacy.TradeCryptoAction import com.tangem.tap.common.analytics.events.Token import com.tangem.tap.common.analytics.events.Token.Send.SelectedCurrency.CurrencyType -import com.tangem.tap.common.extensions.dispatchDialogShow -import com.tangem.tap.common.extensions.dispatchErrorNotification -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.stripZeroPlainString +import com.tangem.tap.common.extensions.* import com.tangem.tap.common.redux.AppDialog import com.tangem.tap.common.redux.AppState import com.tangem.tap.common.redux.global.GlobalAction @@ -211,7 +208,7 @@ private fun sendTransaction( // return@launch // } - val tangemSdk = store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository).sdk + val tangemSdk = store.inject(DaggerGraphState::cardSdkConfigRepository).sdk val linkedTerminalState = tangemSdk.config.linkedTerminal if (card.isStart2Coin) { tangemSdk.config.linkedTerminal = false diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/RequestFeeErrorDialog.kt b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/RequestFeeErrorDialog.kt index 8da2d25445..cc67c24921 100644 --- a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/RequestFeeErrorDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/RequestFeeErrorDialog.kt @@ -18,7 +18,7 @@ object RequestFeeErrorDialog { return AlertDialog.Builder(context).apply { setTitle(R.string.common_fee_error) setMessage(context.getString(R.string.alert_failed_to_send_transaction_message, errorMessage)) - setNegativeButton(R.string.alert_button_send_feedback) { _, _ -> + setNegativeButton(R.string.details_row_title_contact_to_support) { _, _ -> store.dispatch(GlobalAction.SendEmail(SendTransactionFailedEmail(errorMessage))) } setPositiveButton(R.string.common_retry) { _, _ -> dialog.onRetry() } diff --git a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/SendTransactionFailsDialog.kt b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/SendTransactionFailsDialog.kt index 8d1fe0182f..4c300f7aeb 100644 --- a/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/SendTransactionFailsDialog.kt +++ b/app/src/main/java/com/tangem/tap/features/send/ui/dialogs/SendTransactionFailsDialog.kt @@ -30,7 +30,7 @@ object SendTransactionFailsDialog { return AlertDialog.Builder(context).apply { setTitle(R.string.alert_failed_to_send_transaction_title) setMessage(context.getString(R.string.alert_failed_to_send_transaction_message, errorMessage)) - setNeutralButton(R.string.alert_button_send_feedback) { _, _ -> + setNeutralButton(R.string.details_row_title_contact_to_support) { _, _ -> store.dispatch(GlobalAction.SendEmail(SendTransactionFailedEmail(errorMessage))) } setPositiveButton(R.string.common_cancel) { _, _ -> } diff --git a/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/viewmodels/TokensListMigration.kt b/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/viewmodels/TokensListMigration.kt index f4f5e5e72d..3a49bac80a 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/viewmodels/TokensListMigration.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/viewmodels/TokensListMigration.kt @@ -16,6 +16,7 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.tap.common.extensions.dispatchDebugErrorNotification import com.tangem.tap.common.extensions.dispatchOnMain +import com.tangem.tap.common.extensions.inject import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.store import timber.log.Timber @@ -49,7 +50,11 @@ internal class TokensListMigration( is Either.Right -> { currentUserWallet = selectedWalletEither.value - when (val currenciesEither = getCurrenciesUseCase(userWalletId = selectedWalletEither.value.walletId)) { + when ( + val currenciesEither = getCurrenciesUseCase.getSync( + userWalletId = selectedWalletEither.value.walletId, + ) + ) { is Either.Left -> { Timber.e(currenciesEither.value.toString()) TokensListCryptoCurrencies(coins = emptyList(), tokens = emptyList()) @@ -136,8 +141,8 @@ internal class TokensListMigration( private suspend fun removeCurrenciesIfNeeded(userWalletId: UserWalletId, currencies: List) { if (currencies.isEmpty()) return - val currenciesRepository = store.state.daggerGraphState.get(DaggerGraphState::currenciesRepository) - val walletManagersFacade = store.state.daggerGraphState.get(DaggerGraphState::walletManagersFacade) + val currenciesRepository = store.inject(DaggerGraphState::currenciesRepository) + val walletManagersFacade = store.inject(DaggerGraphState::walletManagersFacade) currenciesRepository.removeCurrencies(userWalletId = userWalletId, currencies = currencies) 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 a3425aeb83..28a73436b5 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 @@ -47,9 +47,7 @@ object TradeCryptoMiddleware { } private val isSendRedesignedEnabled: Boolean - get() = store.state.daggerGraphState.get( - getDependency = DaggerGraphState::sendFeatureToggles, - ).isRedesignedSendEnabled + get() = store.inject(getDependency = DaggerGraphState::sendFeatureToggles).isRedesignedSendEnabled @Suppress("LongMethod", "CyclomaticComplexMethod") private fun handle(state: () -> AppState?, action: TradeCryptoAction) { @@ -106,8 +104,7 @@ object TradeCryptoMiddleware { if (currency is CryptoCurrency.Token && currency.network.isTestnet) { scope.launch { - val walletManager = store.state.daggerGraphState - .get(DaggerGraphState::walletManagersFacade) + val walletManager = store.inject(DaggerGraphState::walletManagersFacade) .getOrCreateWalletManager( userWalletId = action.userWallet.walletId, blockchain = blockchain, @@ -175,8 +172,7 @@ object TradeCryptoMiddleware { val blockchain = Blockchain.fromId(currency.network.id.value) scope.launch { - val walletManager = store.state.daggerGraphState - .get(DaggerGraphState::walletManagersFacade) + val walletManager = store.inject(DaggerGraphState::walletManagersFacade) .getOrCreateWalletManager( userWalletId = action.userWallet.walletId, blockchain = blockchain, @@ -234,8 +230,7 @@ object TradeCryptoMiddleware { val blockchain = Blockchain.fromId(currency.network.id.value) scope.launch { - val walletManager = store.state.daggerGraphState - .get(DaggerGraphState::walletManagersFacade) + val walletManager = store.inject(DaggerGraphState::walletManagersFacade) .getOrCreateWalletManager( userWalletId = action.userWallet.walletId, blockchain = blockchain, diff --git a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt index 555f73988c..5e03eed1a5 100644 --- a/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/welcome/redux/WelcomeMiddleware.kt @@ -8,6 +8,7 @@ import com.tangem.common.doOnSuccess import com.tangem.common.flatMap import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic import com.tangem.core.navigation.AppScreen import com.tangem.core.navigation.NavigationAction import com.tangem.domain.common.util.cardTypesResolver @@ -16,9 +17,9 @@ import com.tangem.domain.userwallets.UserWalletBuilder import com.tangem.domain.wallets.legacy.unlockIfLockable import com.tangem.tap.* import com.tangem.tap.common.analytics.converters.ParamCardCurrencyConverter -import com.tangem.core.analytics.models.Basic import com.tangem.tap.common.extensions.dispatchOnMain import com.tangem.tap.common.extensions.dispatchWithMain +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.onUserWalletSelected import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler @@ -169,10 +170,10 @@ internal class WelcomeMiddleware { } private suspend inline fun scanCardInternal(crossinline onCardScanned: suspend (ScanResponse) -> Unit) { - store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( + store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( isBiometricsRequestPolicy = preferencesStorage.shouldSaveAccessCodes, ) - store.state.daggerGraphState.get(DaggerGraphState::scanCardProcessor).scan( + store.inject(DaggerGraphState::scanCardProcessor).scan( analyticsEvent = Basic.CardWasScanned(AnalyticsParam.ScannedFrom.SignIn), onSuccess = { scanResponse -> scope.launch { onCardScanned(scanResponse) } diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/CurrencyExchangeManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/CurrencyExchangeManager.kt index 7b0d4d5a93..1f0dc4ea68 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/CurrencyExchangeManager.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/CurrencyExchangeManager.kt @@ -8,6 +8,7 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.extensions.Result import com.tangem.domain.models.scan.CardDTO +import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.extensions.safeUpdate import com.tangem.tap.common.redux.global.CryptoCurrencyName import com.tangem.tap.common.redux.global.GlobalAction @@ -105,7 +106,7 @@ suspend fun buyErc20TestnetTokens(card: CardDTO, walletManager: EthereumWalletMa val signer = TangemSigner( card = card, - tangemSdk = store.state.daggerGraphState.get(DaggerGraphState::cardSdkConfigRepository).sdk, + tangemSdk = store.inject(DaggerGraphState::cardSdkConfigRepository).sdk, initialMessage = Message(), ) { signResponse -> store.dispatch( 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 2335c5caeb..4c7a8f4461 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -19,10 +19,10 @@ 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.blockchain.externallinkprovider.TxExploreState import com.tangem.blockchain.network.ResultChecker import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.hexToBytes -import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.extensions.fromNetworkId import com.tangem.domain.walletmanager.WalletManagersFacade @@ -40,7 +40,6 @@ import java.math.RoundingMode @Suppress("LargeClass") class TransactionManagerImpl( private val appStateHolder: AppStateHolder, - private val analytics: AnalyticsEventHandler, private val cardSdkConfigRepository: CardSdkConfigRepository, private val walletManagersFacade: WalletManagersFacade, ) : TransactionManager { @@ -119,7 +118,10 @@ class TransactionManagerImpl( override fun getExplorerTransactionLink(networkId: String, txAddress: String): String { val blockchain = Blockchain.fromNetworkId(networkId) ?: error("blockchain not found") - return blockchain.getExploreTxUrl(txAddress) + return when (val txUrlState = blockchain.getExploreTxUrl(txAddress)) { + TxExploreState.Unsupported -> "" + is TxExploreState.Url -> txUrlState.url + } } override fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? { @@ -144,10 +146,6 @@ class TransactionManagerImpl( } } - override fun getNativeTokenDecimals(networkId: String): Int { - return Blockchain.fromNetworkId(networkId)?.decimals() ?: error("blockchain not found") - } - override suspend fun updateWalletManager(networkId: String, derivationPath: String?) { val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } getActualWalletManager(blockchain, derivationPath).update() @@ -205,7 +203,6 @@ class TransactionManagerImpl( return ProxyNetworkInfo( name = blockchain.fullName, blockchainId = blockchain.id, - blockchainCurrency = blockchain.currency, ) } diff --git a/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt b/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt index 14000bb456..8d098f79c1 100644 --- a/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/UserWalletManagerImpl.kt @@ -164,11 +164,6 @@ class UserWalletManagerImpl( } } - override fun getNetworkCurrency(networkId: String): String { - val blockchain = requireNotNull(Blockchain.fromNetworkId(networkId)) { "blockchain not found" } - return blockchain.currency - } - @Throws(IllegalArgumentException::class) private suspend fun getActualWalletManager(blockchain: Blockchain, derivationPath: String?): WalletManager { val selectedUserWallet = requireNotNull( diff --git a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt index d72bae0238..d3f44858b1 100644 --- a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt +++ b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt @@ -1,6 +1,5 @@ package com.tangem.tap.proxy.di -import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -43,13 +42,11 @@ internal object ProxyModule { @Singleton fun provideTransactionManager( appStateHolder: AppStateHolder, - analytics: AnalyticsEventHandler, cardSdkConfigRepository: CardSdkConfigRepository, walletManagersFacade: WalletManagersFacade, ): TransactionManager { return TransactionManagerImpl( appStateHolder = appStateHolder, - analytics = analytics, cardSdkConfigRepository = cardSdkConfigRepository, walletManagersFacade = walletManagersFacade, ) diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt index 4a543195d6..0afb10ab8d 100644 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt @@ -8,10 +8,16 @@ import com.tangem.domain.apptheme.repository.AppThemeModeRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.ScanCardUseCase +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.feedback.FeedbackManagerFeatureToggles +import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.NetworksRepository import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.legacy.UserWalletsListManager +import com.tangem.domain.wallets.legacy.UserWalletsListManagerFeatureToggles import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.feature.qrscanning.QrScanningRouter import com.tangem.features.managetokens.featuretoggles.ManageTokensFeatureToggles @@ -55,11 +61,10 @@ data class DaggerGraphState( val currenciesRepository: CurrenciesRepository? = null, val blockchainDataStorage: BlockchainDataStorage? = null, val accountCreator: AccountCreator? = null, -) : StateType { - - inline fun get(getDependency: DaggerGraphState.() -> T?): T { - return requireNotNull(getDependency()) { - "${T::class.simpleName} isn't initialized " - } - } -} \ No newline at end of file + val userWalletsListManagerFeatureToggles: UserWalletsListManagerFeatureToggles? = null, + val generalUserWalletsListManager: UserWalletsListManager? = null, + val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase? = null, + val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase? = null, + val cardRepository: CardRepository? = null, + val feedbackManagerFeatureToggles: FeedbackManagerFeatureToggles? = null, +) : StateType \ No newline at end of file diff --git a/app/src/main/res/menu/menu_onboarding.xml b/app/src/main/res/menu/menu_onboarding.xml index 28e7e73a09..fb7ee134ce 100644 --- a/app/src/main/res/menu/menu_onboarding.xml +++ b/app/src/main/res/menu/menu_onboarding.xml @@ -3,6 +3,6 @@ xmlns:app="http://schemas.android.com/apk/res-auto"> \ No newline at end of file diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 522cd3257e..e4239e40d6 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.datasource" +} + dependencies { /** Project */ diff --git a/core/datasource/src/main/AndroidManifest.xml b/core/datasource/src/main/AndroidManifest.xml index a87322003a..f1b5bc5af0 100644 --- a/core/datasource/src/main/AndroidManifest.xml +++ b/core/datasource/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CurrenciesResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CurrenciesResponse.kt index bab2e292de..c2799b1c2d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CurrenciesResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/CurrenciesResponse.kt @@ -1,11 +1,15 @@ package com.tangem.datasource.api.tangemTech.models import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class CurrenciesResponse( @Json(name = "currencies") val currencies: List, + @Json(name = "imageHost") val imageHost: String? = null, ) { + @JsonClass(generateAdapter = true) data class Currency( @Json(name = "id") val id: String, @Json(name = "code") val code: String, // this is an uppercase id @@ -13,5 +17,7 @@ data class CurrenciesResponse( @Json(name = "rateBTC") val rateBTC: String, @Json(name = "unit") val unit: String, // $, €, ₽ @Json(name = "type") val type: String, + val iconSmallUrl: String? = null, + val iconMediumUrl: String? = null, ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt index e36b36096b..bc295ea874 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/AppPreferencesStoreModule.kt @@ -21,7 +21,7 @@ internal object AppPreferencesStoreModule { fun provideAppPreferencesStore( @ApplicationContext appContext: Context, dispatchers: CoroutineDispatcherProvider, - @NetworkMoshi moshi: Moshi, + @SdkMoshi moshi: Moshi, ): AppPreferencesStore { return AppPreferencesStore( preferencesDataStore = PreferencesDataStore.getInstance(context = appContext, dispatcher = dispatchers.io), diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/appcurrency/implementation/DefaultAvailableAppCurrenciesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/appcurrency/implementation/DefaultAvailableAppCurrenciesStore.kt index ed9d257ec8..6ee08097c5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/appcurrency/implementation/DefaultAvailableAppCurrenciesStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/appcurrency/implementation/DefaultAvailableAppCurrenciesStore.kt @@ -15,8 +15,20 @@ internal class DefaultAvailableAppCurrenciesStore( } override suspend fun store(response: CurrenciesResponse) { - val currencies = response.currencies.associateBy(CurrenciesResponse.Currency::code) + val currencies = response.currencies + .map { + it.copy( + iconSmallUrl = response.imageHost?.plus(IMAGE_SMALL)?.format(it.id), + iconMediumUrl = response.imageHost?.plus(IMAGE_MEDIUM)?.format(it.id), + ) + } + .associateBy(CurrenciesResponse.Currency::code) dataStore.store(currencies) } + + private companion object { + const val IMAGE_MEDIUM = "medium/%s.png" + const val IMAGE_SMALL = "small/%s.png" + } } \ 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 0c38cd669b..2b1de6b7b0 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 @@ -45,7 +45,7 @@ class AppPreferencesStore( * @see getObjectList * */ inline fun MutablePreferences.getObject(key: Preferences.Key): T? { - val adapter = moshi.adapter(T::class.java) // TODO: Support parameterized types + val adapter = moshi.adapter(T::class.java) return this[key]?.let(adapter::fromJson) } @@ -55,12 +55,21 @@ class AppPreferencesStore( return this[key]?.let(adapter::fromJson) } + /** Get list of data [T] by string [key] or default */ + inline fun MutablePreferences.getObjectListOrDefault( + key: Preferences.Key, + default: List, + ): List { + val adapter = moshi.adapter>(Types.newParameterizedType(List::class.java, T::class.java)) + return this[key]?.let(adapter::fromJson) ?: default + } + /** Get map with [String] key and value [V] by string [key] from [MutablePreferences] */ - inline fun MutablePreferences.getObjectMap(key: Preferences.Key): Map? { + 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) + return this[key]?.let(adapter::fromJson).orEmpty() } /** 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 c5c54133ac..22d8aa8ea3 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 @@ -3,10 +3,12 @@ package com.tangem.datasource.local.preferences import androidx.datastore.preferences.core.* import com.tangem.datasource.local.preferences.PreferencesKeys.APP_LAUNCH_COUNT_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.FUNDS_FOUND_DATE_KEY +import com.tangem.datasource.local.preferences.PreferencesKeys.IS_TANGEM_TOS_ACCEPTED_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.SAVE_USER_WALLETS_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.USED_CARDS_INFO_KEY import com.tangem.datasource.local.preferences.PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY +import com.tangem.datasource.local.preferences.PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN /** * All preferences keys that DataStore is stored. @@ -54,6 +56,14 @@ object PreferencesKeys { } val FEATURE_TOGGLES_KEY by lazy { stringPreferencesKey(name = "featureToggles") } + + val WAS_TWINS_ONBOARDING_SHOWN by lazy { booleanPreferencesKey(name = "twinsOnboardingShown") } + + val IS_TANGEM_TOS_ACCEPTED_KEY by lazy { booleanPreferencesKey(name = "tangem_tos_accepted") } + + val APP_LOGS_KEY by lazy { stringPreferencesKey(name = "app_logs") } + + fun getStart2CoinTOSAcceptedKey(region: String?) = booleanPreferencesKey(name = "start2Coin_tos_accepted_$region") } /** Preferences keys set that should be migrated from "PreferencesDataSource" to a new DataStore */ @@ -65,6 +75,8 @@ internal fun getTapPrefKeysToMigrate(): Set { FUNDS_FOUND_DATE_KEY, USER_WAS_INTERACT_WITH_RATING_KEY, USED_CARDS_INFO_KEY, + WAS_TWINS_ONBOARDING_SHOWN, + IS_TANGEM_TOS_ACCEPTED_KEY, ) .map(Preferences.Key<*>::name) .toSet() diff --git a/core/featuretoggles/build.gradle.kts b/core/featuretoggles/build.gradle.kts index 5354b55813..d775494d39 100644 --- a/core/featuretoggles/build.gradle.kts +++ b/core/featuretoggles/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.core.featuretoggles" +} + dependencies { /** DI */ diff --git a/core/featuretoggles/src/main/AndroidManifest.xml b/core/featuretoggles/src/main/AndroidManifest.xml deleted file mode 100644 index f96c64f1db..0000000000 --- a/core/featuretoggles/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ 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 e76218c369..1b7b643cea 100644 --- a/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/featuretoggles/src/main/assets/configs/feature_toggles_config.json @@ -14,5 +14,13 @@ { "name": "REDESIGNED_SEND_SCREEN_ENABLED", "version": "undefined" + }, + { + "name": "GENERAL_USER_WALLETS_LIST_MANAGER_ENABLED", + "version": "5.7.0" + }, + { + "name": "LOCAL_USER_LOGS_ENABLED", + "version": "5.8.0" } ] diff --git a/core/res/build.gradle.kts b/core/res/build.gradle.kts index 1fbece85d5..c2fce27feb 100644 --- a/core/res/build.gradle.kts +++ b/core/res/build.gradle.kts @@ -2,4 +2,8 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) id("configuration") +} + +android { + namespace = "com.tangem.core.res" } \ No newline at end of file diff --git a/core/res/src/main/AndroidManifest.xml b/core/res/src/main/AndroidManifest.xml deleted file mode 100644 index 1420909c0e..0000000000 --- a/core/res/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index ffb87108d1..ea038372cd 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -91,8 +91,7 @@ Посмотреть историю транзакций Обозреватель Комиссия - Сетевые комиссии – это плата пользователя за обработку и подтверждение транзакций. Размер комиссии зависит от нагрузка на сеть, объема транзакции и приоритета исполнения. %s - Подробнее + Сетевые комиссии – это плата пользователя за обработку и подтверждение транзакций. Размер комиссии зависит от нагрузки на сеть, объема транзакции и приоритета исполнения. %s Свое Быстро По рынку @@ -111,6 +110,7 @@ OK Основная карта Вставить + Подробнее Получить Отклонить Перезагрузить @@ -130,6 +130,7 @@ Начать Отправить Успешно + Поддержка Обмен условия участия Ошибка транзакции @@ -181,11 +182,11 @@ Privacy policy %s хэшей Номер карты + Обратиться в поддержку Добавить еще карты Валюта приложения Скрывать балансы жестом переворота Эмитент - Отправить отзыв Подписано Подробности Проверьте подключение с интернетом или переключитесь на другую сеть @@ -452,11 +453,10 @@ Приготовьте свою карту Уже содержится в введенном адресе Сумма - Вычесть из суммы отправки - Сумма к получению %s - Поддержка Транзакция не выполнена Причина: %1$s\nКод: %2$s + Недостаточно средств для покрытия комиссии сети. Вычесть комиссию из отправляемой сумму? + Вычесть %1$s в %2$s Адрес Код назначения @@ -478,14 +478,12 @@ Комиссия не превысит Максимальная cумма комиссии Сетевая комиссия - Сумма отправки будет уменьшена для покрытия выбранного уровня комиссии + Сумма отправки будет уменьшена на %1$s для покрытия выбранного уровня комиссии. Получателю будет отправлено %2$s. Покрытие сетевой комиссии Недостаточно средств для перевода, так как сумма комиссии и сумма перевода в совокупности больше имеющегося баланса Недостаточно средств Увеличение комиссии Комиссия при переводе всего баланса выше. Для того, чтобы снизить комиссию Вы можете оставить 0.01. - Оставить %s XTZ - Отправить все Установлена высокая комиссия Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна. Включенная комиссия превышает сумму перевода, что приводит к отрицательному значению @@ -495,6 +493,10 @@ Сумма отправки не может быть менее %1$s Обратите внимание, что при определенных параметрах комиссии возможны задержки по вашей транзакции Возможны задержки по транзакции + Лимит транзакции + Из-за ограничений %1$s в одну транзакцию может поместиться только %2$s UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму. + Уменьшить до %s + Уменьшить на %s Необязательное Последние Получатель @@ -540,6 +542,7 @@ Комиссии В сумму включено: \n• комиссия провайдера сервиса\n• комиссия сети за отправку %s от биржи обратно на адрес пользователя В сумму включена комиссия провайдера сервиса. + Сумма отправки будет уменьшена для покрытия выбранного уровня комиссии Недостаточно средств для оплаты комиссии на вашем %1$s кошельке для создания транзакции. Сначала пополните свой %2$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 6f802b6be1..bef56ab91b 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -130,7 +130,6 @@ 連結更多錢包 App Currency 發行人 - 發送反饋 簽署 更多 檢查您的網路連接或切換到其他網絡 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 052973065a..cada81a5ff 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -90,7 +90,6 @@ Explorer Fee Network fees are charges users pay to process and confirm transactions. The fee amount can be affected by network congestion, transaction size, and execution priority. %s - Read more Custom Fast Market @@ -110,6 +109,7 @@ OK Primary Card Paste + Read more Receive Reject Reload @@ -129,6 +129,7 @@ Start Submit Success + Support Swap terms and conditions Transaction failed @@ -180,11 +181,11 @@ Privacy policy %s hashes Card ID + Contact to Support Link More Cards App Currency Flip-to-Hide Balances Issuer - Send Feedback Signed Details Check your internet connection or switch to a different network @@ -393,6 +394,10 @@ By balance Organize tokens Ungroup + Select from the gallery + Settings + Camera access denied + You have not given access to your camera %1$s %2$s address on %3$s network %1$s (%2$s) on %3$s network Send only %s to this address. Sending any other currency will result in its irreversible loss. @@ -450,11 +455,11 @@ Get your card ready! Already included in the entered address Amount - Subtract from send amount - The recipient will receive %s - Support The transaction is not completed Reason: %1$s\nCode: %2$s + Not enough funds to cover the network commission. Subtract the commission from the amount sent? + Subtract + Вы указали комиссию ниже рекомендуемой, это может привести к задержке исполнения вашей транзакции. Продолжить? Confirm %1$s at %2$s Address @@ -482,14 +487,12 @@ Maximum fee amount Numbers only for Destination Tag Network fee - Sending amount will be reduced to cover the selected fee level + Sending amount will be reduced by %1$s to cover the selected commission level. The recipient will get %2$s. Network fee coverage Insufficient funds for the transfer, as the total of the fee and transfer amount exceeds the existing balance Total exceeds balance Fee is increased The fee for transferring the entire balance is higher. To reduce the commission, you can leave 0.01. - Reduce by %s XTZ - No, send all Custom fee is high The commission amount is %s times the recommended amount. Make sure that the custom settings are correct. The included commission exceeds the transfer amount, leading to a negative value @@ -500,9 +503,11 @@ Kindly be aware that your transaction may experience delays under specific fee settings Transaction delays are possible Transaction limitation - Due to %1$s limitations only %2$s UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. + Due to %1$s limitations only %2$s UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. Existential deposit The account will be wiped from the blockchain if a balance goes below the existential deposit. Please ensure that the remaining balance after sending will not be less than %s. + Reduce by %s + Reduce to %s Optional Please align your QR code with the square to scan it. Ensure you scan %s network address. Recent @@ -553,6 +558,7 @@ Fees The amount includes:\n• service provider\'s fee\n• network fee for sending %s from the exchange back to the user\'s address. The amount includes the service provider\'s fee. + Sending amount will be reduced to cover the selected fee level Insufficient funds in your %1$s wallet to cover fees. Top up your %2$s wallet first. Transaction in progress... Waiting diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 62f9551fa1..e2abfa0f4b 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -4,6 +4,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.core.ui" +} + dependencies { /** Project - Common */ implementation(projects.common) diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml deleted file mode 100644 index c5a36c7e17..0000000000 --- a/core/ui/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/ResizableText.kt b/core/ui/src/main/java/com/tangem/core/ui/components/ResizableText.kt index 8801cb7983..e63aa1c8e3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/ResizableText.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/ResizableText.kt @@ -1,6 +1,7 @@ package com.tangem.core.ui.components import androidx.annotation.FloatRange +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material.LocalTextStyle import androidx.compose.material.Text import androidx.compose.runtime.* @@ -84,9 +85,11 @@ fun ResizableText( var readyToDraw by remember { mutableStateOf(value = false) } Text( - modifier = modifier.drawWithContent { - if (readyToDraw) drawContent() - }, + modifier = modifier + .drawWithContent { + if (readyToDraw) drawContent() + } + .wrapContentHeight(), text = text, color = color, fontSize = fontSize, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt index 1626a42bce..efbf7b9f2a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt @@ -163,11 +163,13 @@ private fun PriceChangeInPercent(config: PriceChangeState.Content) { id = when (type) { PriceChangeType.UP -> R.drawable.ic_arrow_up_8 PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8 + PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8 }, ), tint = when (type) { PriceChangeType.UP -> TangemTheme.colors.icon.accent PriceChangeType.DOWN -> TangemTheme.colors.icon.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive }, contentDescription = null, ) @@ -177,6 +179,7 @@ private fun PriceChangeInPercent(config: PriceChangeState.Content) { color = when (type) { PriceChangeType.UP -> TangemTheme.colors.text.accent PriceChangeType.DOWN -> TangemTheme.colors.text.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors.text.disabled }, style = TangemTheme.typography.body2, ) @@ -251,6 +254,14 @@ private class WalletMarketPriceBlockStateProvider : CollectionPreviewParameterPr type = PriceChangeType.UP, ), ), + MarketPriceBlockState.Content( + currencySymbol = "BTC", + price = "98900 $", + priceChangeConfig = PriceChangeState.Content( + valueInPercent = "0.00%", + type = PriceChangeType.NEUTRAL, + ), + ), MarketPriceBlockState.Loading(currencySymbol = "BTC"), MarketPriceBlockState.Error(currencySymbol = "BTC"), ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt index 5130e77203..8db483f50c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt @@ -5,9 +5,4 @@ sealed class PriceChangeState { data class Content(val valueInPercent: String, val type: PriceChangeType) : PriceChangeState() object Unknown : PriceChangeState() -} - -/** Price changing type */ -enum class PriceChangeType { - UP, DOWN } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt new file mode 100644 index 0000000000..de78fc1368 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeType.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.components.marketprice + +/** Price changing type */ +enum class PriceChangeType { + UP, DOWN, NEUTRAL, +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/utils/PriceChangeConverter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/utils/PriceChangeConverter.kt new file mode 100644 index 0000000000..5d8bb93cb3 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/utils/PriceChangeConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.core.ui.components.marketprice.utils + +import com.tangem.core.ui.components.marketprice.PriceChangeType +import java.math.BigDecimal +import java.math.RoundingMode + +object PriceChangeConverter { + + fun fromBigDecimal(value: BigDecimal?, scale: Int = 2): PriceChangeType { + val formattedValue = value + ?.movePointRight(2) + ?.setScale(scale, RoundingMode.HALF_UP) + ?: return PriceChangeType.NEUTRAL + + return when (formattedValue.signum()) { + 1 -> PriceChangeType.UP + -1 -> PriceChangeType.DOWN + else -> PriceChangeType.NEUTRAL + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt index 95951380ca..f49147879e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt @@ -54,6 +54,9 @@ fun getActiveIconRes(blockchainId: String): Int { "hedera", "hedera/test" -> R.drawable.img_hedera_22 "playa3ull" -> R.drawable.img_playa3ull_22 "DUC" -> R.drawable.img_ducatus_22 + "aurora", "aurora/test" -> R.drawable.img_aurora_22 + "areon", "areon/test" -> R.drawable.img_areon_22 + "pls", "pls/test" -> R.drawable.img_pls_22 else -> R.drawable.ic_alert_24 } } @@ -109,6 +112,9 @@ fun getActiveIconResByNetworkId(networkId: String): Int { "hedera-hashgraph", "hedera/test" -> R.drawable.img_hedera_22 "playa3ull-games" -> R.drawable.img_playa3ull_22 "ducatus" -> R.drawable.img_ducatus_22 + "aurora", "aurora/test" -> R.drawable.img_aurora_22 + "areon", "areon/test" -> R.drawable.img_areon_22 + "pls", "pls/test" -> R.drawable.img_pls_22 else -> R.drawable.ic_alert_24 } } @@ -161,6 +167,9 @@ fun getActiveIconResByCoinId(coinId: String): Int { "hedera-hashgraph" -> R.drawable.img_hedera_22 "playa3ull-games-2" -> R.drawable.img_playa3ull_22 "ducatus" -> R.drawable.img_ducatus_22 + "aurora-near" -> R.drawable.img_aurora_22 + "areon" -> R.drawable.img_areon_22 + "pls" -> R.drawable.img_pls_22 else -> R.drawable.ic_alert_24 } } @@ -216,6 +225,9 @@ fun getGreyedOutIconRes(blockchainId: String): Int { "hedera", "hedera/test" -> R.drawable.ic_hedera_22 "playa3ull" -> R.drawable.ic_playa3ull_22 "DUC" -> R.drawable.ic_ducatus_22 + "aurora", "aurora/test" -> R.drawable.ic_aurora_22 + "areon", "areon/test" -> R.drawable.ic_areon_22 + "pls", "pls/test" -> R.drawable.ic_pls_22 else -> R.drawable.ic_alert_24 } } @@ -271,6 +283,9 @@ fun getGreyedOutIconResByNetworkId(networkId: String): Int { "hedera-hashgraph", "hedera/test" -> R.drawable.ic_hedera_22 "playa3ull-games" -> R.drawable.ic_playa3ull_22 "ducatus" -> R.drawable.ic_ducatus_22 + "aurora", "aurora/test" -> R.drawable.ic_aurora_22 + "areon", "areon/test" -> R.drawable.ic_areon_22 + "pls", "pls/test" -> R.drawable.ic_pls_22 else -> R.drawable.ic_alert_24 } } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_areon_22.xml b/core/ui/src/main/res/drawable/ic_areon_22.xml new file mode 100644 index 0000000000..5c4661ff99 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_areon_22.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/ui/src/main/res/drawable/ic_aurora_22.xml b/core/ui/src/main/res/drawable/ic_aurora_22.xml new file mode 100644 index 0000000000..65d64fc01e --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_aurora_22.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_bitcoin_16.xml b/core/ui/src/main/res/drawable/ic_bitcoin_16.xml index b18c3c9cce..9f9cccbc58 100644 --- a/core/ui/src/main/res/drawable/ic_bitcoin_16.xml +++ b/core/ui/src/main/res/drawable/ic_bitcoin_16.xml @@ -1,5 +1,7 @@ - + diff --git a/core/ui/src/main/res/drawable/ic_elipse_8.xml b/core/ui/src/main/res/drawable/ic_elipse_8.xml new file mode 100644 index 0000000000..dab2aad1bb --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_elipse_8.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_pls_22.xml b/core/ui/src/main/res/drawable/ic_pls_22.xml new file mode 100644 index 0000000000..2c285561f2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_pls_22.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_settings_24.xml b/core/ui/src/main/res/drawable/ic_settings_24.xml new file mode 100644 index 0000000000..480a877aa2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_settings_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/img_areon_22.xml b/core/ui/src/main/res/drawable/img_areon_22.xml new file mode 100644 index 0000000000..23c71490ed --- /dev/null +++ b/core/ui/src/main/res/drawable/img_areon_22.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_aurora_22.xml b/core/ui/src/main/res/drawable/img_aurora_22.xml new file mode 100644 index 0000000000..c1b0be7ce0 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_aurora_22.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/img_pls_22.xml b/core/ui/src/main/res/drawable/img_pls_22.xml new file mode 100644 index 0000000000..5560c5314a --- /dev/null +++ b/core/ui/src/main/res/drawable/img_pls_22.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt index 3c0ca6bd8d..42c4c7b296 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/JobHolder.kt @@ -16,6 +16,11 @@ class JobHolder { this.job?.cancel() this.job = job } + + /** Cancel current [job] */ + fun cancel() { + job?.cancel() + } } fun Job.saveIn(jobHolder: JobHolder) = jobHolder.update(job = this) \ No newline at end of file diff --git a/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/DefaultAppCurrencyRepository.kt b/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/DefaultAppCurrencyRepository.kt index b86e0bb03c..bcc535bed2 100644 --- a/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/DefaultAppCurrencyRepository.kt +++ b/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/DefaultAppCurrencyRepository.kt @@ -77,16 +77,18 @@ internal class DefaultAppCurrencyRepository( } } - private suspend fun fetchDefaultAppCurrency() { - fetchAvailableCurrenciesIfExpired() - - changeAppCurrency(DEFAULT_CURRENCY_CODE) + override suspend fun fetchDefaultAppCurrency(isRefresh: Boolean) { + withContext(dispatchers.io) { + fetchAvailableCurrenciesIfExpired(isRefresh) + val appCurrency = appPreferencesStore.getSyncOrNull(PreferencesKeys.SELECTED_APP_CURRENCY_KEY) + changeAppCurrency(appCurrency ?: DEFAULT_CURRENCY_CODE) + } } - private suspend fun fetchAvailableCurrenciesIfExpired() { + private suspend fun fetchAvailableCurrenciesIfExpired(isRefresh: Boolean = false) { cacheRegistry.invokeOnExpire( key = AVAILABLE_CURRENCIES_CACHE_KEY, - skipCache = false, + skipCache = isRefresh, expireIn = Duration.standardMinutes(AVAILABLE_CURRENCIES_CACHE_KEY_EXPIRE_MINUTES), block = { fetchAvailableCurrencies() }, ) diff --git a/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/utils/AppCurrencyConverter.kt b/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/utils/AppCurrencyConverter.kt index 6b8a6409e8..8717880e9f 100644 --- a/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/utils/AppCurrencyConverter.kt +++ b/data/app-currency/src/main/kotlin/com/tangem/data/appcurrency/utils/AppCurrencyConverter.kt @@ -11,6 +11,8 @@ internal class AppCurrencyConverter : Converter - val usedCards: List? = mutablePreferences.getObjectList( - key = PreferencesKeys.USED_CARDS_INFO_KEY, - ) + appPreferencesStore.editUsedCards(cardId) { it.copy(isScanned = true) } + } - val updatedUsedCards = usedCards?.updateCard(cardId) - ?: listOf(UsedCardInfo(cardId = cardId, isScanned = true)) + override suspend fun startCardActivation(cardId: String) { + appPreferencesStore.editUsedCards(cardId) { it.copy(isActivationStarted = true) } + } - mutablePreferences.setObjectList( - key = PreferencesKeys.USED_CARDS_INFO_KEY, - value = updatedUsedCards, - ) + override suspend fun finishCardActivation(cardId: String) { + appPreferencesStore.editUsedCards(cardId) { + it.copy(isActivationStarted = true, isActivationFinished = true) } } - private fun List.updateCard(cardId: String): List { - val card = find { it.cardId == cardId } ?: UsedCardInfo(cardId = cardId, isScanned = true) - return addOrReplace(item = card.copy(isScanned = true), predicate = { it.cardId == cardId }) + override suspend fun finishCardsActivation(cardIds: List) { + appPreferencesStore.editData { mutablePreferences -> + val usedCards = mutablePreferences.getUsedCards() + + val newCards = cardIds.mapNotNull { newCardId -> + if (usedCards.none { it.cardId == newCardId }) { + createDefaultUsedCardInfo(cardId = newCardId) + } else { + null + } + } + + val updatedUsedCards = (usedCards + newCards).map { card -> + if (cardIds.contains(card.cardId)) { + card.copy(isActivationStarted = true, isActivationFinished = true) + } else { + card + } + } + + mutablePreferences.setObjectList(key = PreferencesKeys.USED_CARDS_INFO_KEY, value = updatedUsedCards) + } } + + override suspend fun isActivationStarted(cardId: String): Boolean { + return getUsedCardSync(cardId)?.isActivationStarted ?: false + } + + override suspend fun isActivationFinished(cardId: String): Boolean { + return getUsedCardSync(cardId)?.isActivationFinished ?: false + } + + override suspend fun isActivationInProgress(cardId: String): Boolean { + val card = getUsedCardSync(cardId) ?: return false + + return card.isActivationStarted && !card.isActivationFinished + } + + override suspend fun isTangemTOSAccepted(): Boolean { + return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.IS_TANGEM_TOS_ACCEPTED_KEY, default = false) + } + + override suspend fun isStart2CoinTOSAccepted(cardId: String): Boolean { + return appPreferencesStore.getSyncOrDefault( + key = PreferencesKeys.getStart2CoinTOSAcceptedKey(region = getRegion(cardId)), + default = false, + ) + } + + override suspend fun acceptTangemTOS() { + return appPreferencesStore.store(key = PreferencesKeys.IS_TANGEM_TOS_ACCEPTED_KEY, true) + } + + override suspend fun acceptStart2CoinTOS(cardId: String) { + appPreferencesStore.store( + key = PreferencesKeys.getStart2CoinTOSAcceptedKey(region = getRegion(cardId)), + value = true, + ) + } + + private suspend fun AppPreferencesStore.editUsedCards(cardId: String, update: (UsedCardInfo) -> UsedCardInfo) { + editData { mutablePreferences -> + val usedCards = mutablePreferences.getUsedCards() + + val updatedUsedCards = usedCards.updateCard(cardId = cardId, update = update) + + mutablePreferences.setObjectList(key = PreferencesKeys.USED_CARDS_INFO_KEY, value = updatedUsedCards) + } + } + + private fun MutablePreferences.getUsedCards(): List { + return with(appPreferencesStore) { + getObjectListOrDefault(key = PreferencesKeys.USED_CARDS_INFO_KEY, default = mutableListOf()) + } + } + + private fun List.updateCard( + cardId: String, + update: (UsedCardInfo) -> UsedCardInfo, + ): List { + val card = find { it.cardId == cardId } ?: createDefaultUsedCardInfo(cardId = cardId) + return addOrReplace(item = update(card), predicate = { it.cardId == cardId }) + } + + private suspend fun getUsedCardSync(cardId: String): UsedCardInfo? { + return appPreferencesStore.getObjectListSync(PreferencesKeys.USED_CARDS_INFO_KEY) + .firstOrNull { it.cardId == cardId } + } + + private fun getRegion(cardId: String): String? { + if (cardId.isEmpty()) return null + + return when (cardId[1]) { + '0' -> "fr" + '1' -> "ch" + '2' -> "at" + else -> null + } + } + + private fun createDefaultUsedCardInfo(cardId: String) = UsedCardInfo(cardId = cardId) } \ No newline at end of file diff --git a/data/onboarding/.gitignore b/data/onboarding/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/data/onboarding/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/onboarding/build.gradle.kts b/data/onboarding/build.gradle.kts new file mode 100644 index 0000000000..91e15d90c9 --- /dev/null +++ b/data/onboarding/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + id("configuration") +} + +android { + namespace = "com.tangem.data.onboarding" +} + +dependencies { + + // region AndroidX libraries + implementation(deps.androidx.datastore) + // endregion + + // region DI + implementation(deps.hilt.core) + kapt(deps.hilt.kapt) + // endregion + + // region Core modules + implementation(projects.core.datasource) + // endregion + + // region Domain modules + implementation(projects.domain.onboarding) + // endregion +} \ No newline at end of file diff --git a/data/onboarding/src/main/kotlin/com/tangem/data/onboarding/DefaultOnboardingRepository.kt b/data/onboarding/src/main/kotlin/com/tangem/data/onboarding/DefaultOnboardingRepository.kt new file mode 100644 index 0000000000..683ce6d376 --- /dev/null +++ b/data/onboarding/src/main/kotlin/com/tangem/data/onboarding/DefaultOnboardingRepository.kt @@ -0,0 +1,33 @@ +package com.tangem.data.onboarding + +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.get +import com.tangem.datasource.local.preferences.utils.getSyncOrDefault +import com.tangem.datasource.local.preferences.utils.store +import com.tangem.domain.onboarding.repository.OnboardingRepository +import kotlinx.coroutines.flow.Flow + +/** + * Default implementation of [OnboardingRepository] + * + * @property appPreferencesStore app preferences store + * +[REDACTED_AUTHOR] + */ +internal class DefaultOnboardingRepository( + private val appPreferencesStore: AppPreferencesStore, +) : OnboardingRepository { + + override fun wasTwinsOnboardingShown(): Flow { + return appPreferencesStore.get(key = PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN, default = false) + } + + override suspend fun wasTwinsOnboardingShownSync(): Boolean { + return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN, default = false) + } + + override suspend fun saveTwinsOnboardingShown() { + appPreferencesStore.store(key = PreferencesKeys.WAS_TWINS_ONBOARDING_SHOWN, value = true) + } +} \ No newline at end of file diff --git a/data/onboarding/src/main/kotlin/com/tangem/data/onboarding/di/OnboardingRepositoryModule.kt b/data/onboarding/src/main/kotlin/com/tangem/data/onboarding/di/OnboardingRepositoryModule.kt new file mode 100644 index 0000000000..391bade42f --- /dev/null +++ b/data/onboarding/src/main/kotlin/com/tangem/data/onboarding/di/OnboardingRepositoryModule.kt @@ -0,0 +1,21 @@ +package com.tangem.data.onboarding.di + +import com.tangem.data.onboarding.DefaultOnboardingRepository +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.onboarding.repository.OnboardingRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object OnboardingRepositoryModule { + + @Provides + @Singleton + fun provideOnboardingRepository(appPreferencesStore: AppPreferencesStore): OnboardingRepository { + return DefaultOnboardingRepository(appPreferencesStore = appPreferencesStore) + } +} \ No newline at end of file diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts index 5daa9b86f0..f12116b4cf 100644 --- a/data/settings/build.gradle.kts +++ b/data/settings/build.gradle.kts @@ -22,9 +22,15 @@ dependencies { implementation(deps.androidx.datastore) - /** DI */ + // region DI implementation(deps.hilt.android) kapt(deps.hilt.kapt) + // endregion + // region Others dependencies + implementation(deps.jodatime) implementation(deps.kotlin.coroutines) + implementation(deps.moshi) + implementation(deps.moshi.kotlin) + // endregion } 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 8d3c6f18bc..4e7ff03252 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 @@ -3,11 +3,14 @@ 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.getObjectMap import com.tangem.datasource.local.preferences.utils.getSyncOrDefault import com.tangem.datasource.local.preferences.utils.store +import com.tangem.domain.settings.models.AppLogsModel import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.withContext +import org.joda.time.DateTime internal class DefaultSettingsRepository( private val preferencesDataSource: PreferencesDataSource, @@ -32,4 +35,38 @@ internal class DefaultSettingsRepository( value = isEnabled, ) } + + override suspend fun getAppLogs(): List { + return appPreferencesStore.getObjectMap(key = PreferencesKeys.APP_LOGS_KEY) + .map { AppLogsModel(timestamp = it.key.toLong(), message = it.value) } + } + + override suspend fun updateAppLogs(message: String) { + val newLogs = DateTime.now().millis.toString() to message + + appPreferencesStore.editData { preferences -> + val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) + + preferences.setObjectMap(key = PreferencesKeys.APP_LOGS_KEY, value = savedLogs + newLogs) + } + } + + override suspend fun deleteDeprecatedLogs() { + val threeDaysAgo = getThreeDaysAgoTimestamp() + + appPreferencesStore.editData { preferences -> + val savedLogs = preferences.getObjectMap(PreferencesKeys.APP_LOGS_KEY) + + preferences.setObjectMap( + key = PreferencesKeys.APP_LOGS_KEY, + value = savedLogs.filterKeys { it.toLong() > threeDaysAgo }, + ) + } + } + + private fun getThreeDaysAgoTimestamp(): Long = DateTime.now().minusDays(THREE_DAYS).millis + + private companion object { + const val THREE_DAYS = 3 + } } \ No newline at end of file diff --git a/data/source/preferences/build.gradle.kts b/data/source/preferences/build.gradle.kts index 7cd9e54515..3768a33a8c 100644 --- a/data/source/preferences/build.gradle.kts +++ b/data/source/preferences/build.gradle.kts @@ -5,6 +5,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.data.source.preferences" +} + dependencies { implementation(deps.androidx.core.ktx) implementation(deps.moshi) diff --git a/data/source/preferences/src/main/AndroidManifest.xml b/data/source/preferences/src/main/AndroidManifest.xml deleted file mode 100644 index 67568f1b9e..0000000000 --- a/data/source/preferences/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/PreferencesDataSource.kt b/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/PreferencesDataSource.kt index d16da10d47..e12d679fd7 100644 --- a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/PreferencesDataSource.kt +++ b/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/PreferencesDataSource.kt @@ -3,10 +3,6 @@ package com.tangem.data.source.preferences import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit -import com.tangem.common.json.MoshiJsonConverter -import com.tangem.data.source.preferences.adapters.BigDecimalAdapter -import com.tangem.data.source.preferences.storage.DisclaimerPrefStorage -import com.tangem.data.source.preferences.storage.UsedCardsPrefStorage import javax.inject.Inject // 🔥FIXME: Only logic to work with preferences must be here, must be separated to repositories @@ -14,22 +10,11 @@ import javax.inject.Inject @Deprecated("Create repository instead") class PreferencesDataSource @Inject internal constructor(applicationContext: Context) { - val usedCardsPrefStorage: UsedCardsPrefStorage - val disclaimerPrefStorage: DisclaimerPrefStorage - private val preferences: SharedPreferences = applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) - private val moshiConverter = MoshiJsonConverter( - adapters = listOf(BigDecimalAdapter()) + MoshiJsonConverter.getTangemSdkAdapters(), - typedAdapters = MoshiJsonConverter.getTangemSdkTypedAdapters(), - ) - init { incrementLaunchCounter() - usedCardsPrefStorage = UsedCardsPrefStorage(preferences, moshiConverter) - usedCardsPrefStorage.migrate() - disclaimerPrefStorage = DisclaimerPrefStorage(preferences) } var shouldShowSaveUserWalletScreen: Boolean @@ -56,14 +41,6 @@ class PreferencesDataSource @Inject internal constructor(applicationContext: Con putBoolean(OPEN_WELCOME_ON_RESUME_KEY, value) } - fun saveTwinsOnboardingShown() { - preferences.edit { putBoolean(TWINS_ONBOARDING_SHOWN_KEY, true) } - } - - fun wasTwinsOnboardingShown(): Boolean { - return preferences.getBoolean(TWINS_ONBOARDING_SHOWN_KEY, false) - } - private fun incrementLaunchCounter() { var count = preferences.getInt(APP_LAUNCH_COUNT_KEY, 0) preferences.edit { putInt(APP_LAUNCH_COUNT_KEY, ++count) } @@ -71,7 +48,6 @@ class PreferencesDataSource @Inject internal constructor(applicationContext: Con companion object { private const val PREFERENCES_NAME = "tapPrefs" - private const val TWINS_ONBOARDING_SHOWN_KEY = "twinsOnboardingShown" private const val APP_LAUNCH_COUNT_KEY = "launchCount" private const val SAVE_WALLET_DIALOG_SHOWN_KEY = "saveUserWalletShown" private const val SAVE_ACCESS_CODES_KEY = "saveAccessCodes" diff --git a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/model/DataSourceUsedCardInfo.kt b/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/model/DataSourceUsedCardInfo.kt deleted file mode 100644 index d05eadc7ca..0000000000 --- a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/model/DataSourceUsedCardInfo.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.data.source.preferences.model - -internal data class DataSourceUsedCardInfo( - val cardId: String, - val isScanned: Boolean = false, - val isActivationStarted: Boolean = false, - val isActivationFinished: Boolean = false, -) \ No newline at end of file diff --git a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/model/DataSourceUsedCardInfoOld.kt b/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/model/DataSourceUsedCardInfoOld.kt deleted file mode 100644 index ee01e6752a..0000000000 --- a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/model/DataSourceUsedCardInfoOld.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.data.source.preferences.model - -internal data class DataSourceUsedCardInfoOld( - val cardId: String, - val isScanned: Boolean = false, - val isActivationStarted: Boolean = false, -) \ No newline at end of file diff --git a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/storage/DisclaimerPrefStorage.kt b/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/storage/DisclaimerPrefStorage.kt deleted file mode 100644 index e91fb9e27c..0000000000 --- a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/storage/DisclaimerPrefStorage.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.data.source.preferences.storage - -import android.content.SharedPreferences -import androidx.core.content.edit - -/** -[REDACTED_AUTHOR] - */ -@Deprecated("Create repository instead") -class DisclaimerPrefStorage internal constructor( - private val preferences: SharedPreferences, -) { - - fun accept(disclaimerKey: String) { - preferences.edit { putBoolean(disclaimerKey, true) } - } - - fun isAccepted(disclaimerKey: String): Boolean { - return preferences.getBoolean(disclaimerKey, false) - } -} \ No newline at end of file diff --git a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/storage/UsedCardsPrefStorage.kt b/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/storage/UsedCardsPrefStorage.kt deleted file mode 100644 index de987e84fc..0000000000 --- a/data/source/preferences/src/main/kotlin/com/tangem/data/source/preferences/storage/UsedCardsPrefStorage.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.tangem.data.source.preferences.storage - -import android.content.SharedPreferences -import androidx.core.content.edit -import com.tangem.common.json.MoshiJsonConverter -import com.tangem.data.source.preferences.model.DataSourceUsedCardInfo -import com.tangem.data.source.preferences.model.DataSourceUsedCardInfoOld - -/** -[REDACTED_AUTHOR] - */ -@Deprecated("Create repository instead") -class UsedCardsPrefStorage internal constructor( - private val preferences: SharedPreferences, - private val jsonConverter: MoshiJsonConverter, -) { - - private val migrationList = mutableListOf( - UserCardInfoToV2(this), - ) - - internal fun migrate() { - migrationList.forEach { it.migrate() } - migrationList.clear() - } - - fun scanned(cardId: String) { - val restoredList = restore() - val foundItem = findCardInfo(cardId, restoredList)?.copy(isScanned = true) - ?: DataSourceUsedCardInfo(cardId, true) - - save(foundItem, restoredList) - } - - fun wasScanned(cardId: String): Boolean { - return findCardInfo(cardId)?.isScanned ?: false - } - - fun activationStarted(cardId: String) { - val restoredList = restore() - val foundItem = findCardInfo(cardId, restoredList)?.copy(isActivationStarted = true) - ?: DataSourceUsedCardInfo(cardId, isActivationStarted = true) - - save(foundItem, restoredList) - } - - fun activationFinished(cardId: String) { - val restoredList = restore() - var foundItem = findCardInfo(cardId, restoredList) ?: DataSourceUsedCardInfo(cardId) - foundItem = foundItem.copy( - isActivationStarted = true, - isActivationFinished = true, - ) - - save(foundItem, restoredList) - } - - fun isActivationStarted(cardId: String): Boolean { - return findCardInfo(cardId)?.isActivationStarted ?: false - } - - fun isActivationFinished(cardId: String): Boolean { - return findCardInfo(cardId)?.isActivationFinished ?: false - } - - fun isActivationInProgress(cardId: String): Boolean { - val cardInfo = findCardInfo(cardId) ?: return false - return cardInfo.isActivationStarted && !cardInfo.isActivationFinished - } - - fun hadFinishedActivation(): Boolean { - return restore().any { it.isActivationFinished } - } - - private fun findCardInfo( - cardId: String, - list: MutableList? = null, - ): DataSourceUsedCardInfo? { - val findInList = list ?: restore() - return findInList.firstOrNull { it.cardId == cardId } - } - - private fun save(usedCardInfo: DataSourceUsedCardInfo?, usedCardsInfo: MutableList) { - val info = usedCardInfo ?: return - - with(usedCardsInfo) { - val index = indexOfFirst { it.cardId == info.cardId } - if (index == -1) { - add(info) - } else { - set(index, info) - } - } - - save(usedCardsInfo) - } - - private fun save(list: MutableList) { - val json = jsonConverter.toJson(list) - preferences.edit { putString(USED_CARDS_INFO_V2, json) } - } - - private fun restore(): MutableList { - val json = preferences.getString(USED_CARDS_INFO_V2, null) ?: return mutableListOf() - return try { - jsonConverter.fromJson(json, jsonConverter.typedList(DataSourceUsedCardInfo::class.java))!! - } catch (ex: Exception) { - preferences.edit(true) { remove(USED_CARDS_INFO_V2) } - mutableListOf() - } - } - - companion object { - private const val USED_CARDS_INFO_V2 = "usedCardsInfo_v2" - private const val USED_CARDS_INFO = "usedCardsInfo" - } - - private class UserCardInfoToV2( - private val storage: UsedCardsPrefStorage, - ) : Migration { - override fun migrate() { - val restoredCardsInfo = restore() - if (restoredCardsInfo.isEmpty()) return - - val newCardsInfo = restoredCardsInfo.map { cardInfo -> - DataSourceUsedCardInfo( - cardId = cardInfo.cardId, - isScanned = cardInfo.isScanned, - isActivationStarted = true, - isActivationFinished = !cardInfo.isActivationStarted, - ) - }.toMutableList() - storage.save(newCardsInfo) - } - - private fun restore(): MutableList { - val json = storage.preferences.getString(USED_CARDS_INFO, null) ?: return mutableListOf() - return try { - storage.jsonConverter.fromJson( - json, - storage.jsonConverter.typedList(DataSourceUsedCardInfoOld::class.java), - )!! - } catch (ex: Exception) { - mutableListOf() - } finally { - storage.preferences.edit(true) { remove(USED_CARDS_INFO) } - } - } - } -} \ No newline at end of file diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt index 5157d1430d..5435cebfd9 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/utils/ResponseCryptoCurrenciesFactory.kt @@ -74,23 +74,40 @@ class ResponseCryptoCurrenciesFactory { ): CryptoCurrency.Coin? { val network = getNetwork(blockchain, responseToken.derivationPath, derivationStyleProvider) ?: return null - // workaround: Dischain was renamed but backend still returns the old name, - // get name and symbol from enum Blockchain until backend renamed - // [REDACTED_JIRA] - val name = if (blockchain == Blockchain.Dischain) blockchain.fullName else responseToken.name - val symbol = if (blockchain == Blockchain.Dischain) blockchain.currency else responseToken.symbol - return CryptoCurrency.Coin( id = getCoinId(network, blockchain.toCoinId()), network = network, - name = name, - symbol = symbol, + name = blockchain.getNameForCoin(responseToken), + symbol = blockchain.getSymbolForCoin(responseToken), decimals = responseToken.decimals, iconUrl = getCoinIconUrl(blockchain), isCustom = isCustomCoin(network), ) } + private fun Blockchain.getNameForCoin(responseToken: UserTokensResponse.Token): String { + return when (this) { + // workaround: Dischain was renamed but backend still returns the old name, + // get name and symbol from enum Blockchain until backend renamed + // [REDACTED_JIRA] + Blockchain.Dischain, + Blockchain.Arbitrum, + -> this.fullName + else -> responseToken.name + } + } + + private fun Blockchain.getSymbolForCoin(responseToken: UserTokensResponse.Token): String { + return when (this) { + // workaround: Dischain was renamed but backend still returns the old name, + // get name and symbol from enum Blockchain until backend renamed + // [REDACTED_JIRA] + Blockchain.Dischain, + -> this.currency + else -> responseToken.symbol + } + } + private fun createToken( blockchain: Blockchain, sdkToken: Token, diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultTxHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultTxHistoryRepository.kt index 9120a85ef1..9f4801ff8c 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultTxHistoryRepository.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/DefaultTxHistoryRepository.kt @@ -4,17 +4,20 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.externallinkprovider.TxExploreState import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.txhistory.repository.paging.TxHistoryPagingSource import com.tangem.datasource.local.txhistory.TxHistoryItemsStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.Network +import com.tangem.domain.txhistory.models.Page import com.tangem.domain.txhistory.models.TxHistoryItem import com.tangem.domain.txhistory.models.TxHistoryState import com.tangem.domain.txhistory.models.TxHistoryStateError import com.tangem.domain.txhistory.repository.TxHistoryRepository import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.walletmanager.utils.SdkPageConverter import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow @@ -25,6 +28,7 @@ class DefaultTxHistoryRepository( private val userWalletsStore: UserWalletsStore, private val txHistoryItemsStore: TxHistoryItemsStore, ) : TxHistoryRepository { + private val sdkPageConverter by lazy { SdkPageConverter() } override suspend fun getTxHistoryItemsCount(userWalletId: UserWalletId, currency: CryptoCurrency): Int { val userWallet = getUserWallet(userWalletId) @@ -66,14 +70,49 @@ class DefaultTxHistoryRepository( override fun getTxExploreUrl(txHash: String, networkId: Network.ID): String { val blockchain = Blockchain.fromId(networkId.value) - // TODO: Fix ton tx urls [REDACTED_TASK_KEY] - return if (blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet) { - "" - } else { - blockchain.getExploreTxUrl(txHash) + return when (val txExploreState = blockchain.getExploreTxUrl(txHash)) { + is TxExploreState.Url -> txExploreState.url + is TxExploreState.Unsupported -> "" } } + override suspend fun getFixedSizeTxHistoryItems( + userWalletId: UserWalletId, + currency: CryptoCurrency, + pageSize: Int, + refresh: Boolean, + ): List { + cacheRegistry.invokeOnExpire( + key = getTxHistoryPageKey(currency, userWalletId, Page.Initial), + skipCache = refresh, + block = { fetchFixedSizeTxHistoryItems(userWalletId, currency, pageSize) }, + ) + val txs = txHistoryItemsStore.getSyncOrNull( + key = TxHistoryItemsStore.Key(userWalletId, currency), + page = Page.Initial, + )?.items + return txs ?: emptyList() + } + + private fun getTxHistoryPageKey(currency: CryptoCurrency, userWalletId: UserWalletId, page: Page): String { + return "tx_history_page_${currency}_${userWalletId}_$page" + } + + private suspend fun fetchFixedSizeTxHistoryItems( + userWalletId: UserWalletId, + currency: CryptoCurrency, + pageSize: Int, + ) { + val wrappedItems = walletManagersFacade.getTxHistoryItems( + userWalletId = userWalletId, + currency = currency, + page = sdkPageConverter.convertBack(Page.Initial), + pageSize = pageSize, + ) + + txHistoryItemsStore.store(TxHistoryItemsStore.Key(userWalletId, currency), wrappedItems) + } + private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet { return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) { "Unable to find user wallet with provided ID: $userWalletId" diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt index ad0bbf4fb3..5108e0387e 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/utils/VisaTxDetailsFactory.kt @@ -1,6 +1,7 @@ package com.tangem.data.visa.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.externallinkprovider.TxExploreState import com.tangem.domain.visa.model.VisaTxDetails import com.tangem.lib.visa.model.VisaTxHistoryResponse @@ -43,7 +44,12 @@ internal class VisaTxDetailsFactory { txHash = request.txHash, txStatus = request.txStatus, fiatCurrency = findCurrencyByNumericCode(request.transactionCurrencyCode), - exploreUrl = request.txHash?.let(walletBlockchain::getExploreTxUrl), + exploreUrl = request.txHash?.let { + when (val txUrl = walletBlockchain.getExploreTxUrl(it)) { + is TxExploreState.Url -> txUrl.url + is TxExploreState.Unsupported -> "" + } + }, ) } } \ No newline at end of file diff --git a/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt b/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt index aa8fcb62fc..9d5261347a 100644 --- a/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt +++ b/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt @@ -4,6 +4,8 @@ data class AppCurrency( val code: String, val name: String, val symbol: String, + val iconSmallUrl: String? = null, + val iconMediumUrl: String? = null, ) { companion object { diff --git a/domain/app-currency/src/main/kotlin/com/tangem/domain/appcurrency/FetchAppCurrenciesUseCase.kt b/domain/app-currency/src/main/kotlin/com/tangem/domain/appcurrency/FetchAppCurrenciesUseCase.kt new file mode 100644 index 0000000000..2264ce5ce4 --- /dev/null +++ b/domain/app-currency/src/main/kotlin/com/tangem/domain/appcurrency/FetchAppCurrenciesUseCase.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.appcurrency + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.domain.appcurrency.error.SelectedAppCurrencyError +import com.tangem.domain.appcurrency.repository.AppCurrencyRepository + +class FetchAppCurrenciesUseCase( + private val appCurrencyRepository: AppCurrencyRepository, +) { + suspend operator fun invoke(): Either = either { + catch( + block = { appCurrencyRepository.fetchDefaultAppCurrency(isRefresh = true) }, + catch = { SelectedAppCurrencyError.DataError(it) }, + ) + } +} \ No newline at end of file diff --git a/domain/app-currency/src/main/kotlin/com/tangem/domain/appcurrency/repository/AppCurrencyRepository.kt b/domain/app-currency/src/main/kotlin/com/tangem/domain/appcurrency/repository/AppCurrencyRepository.kt index 3ba529f8ae..fdde5516e4 100644 --- a/domain/app-currency/src/main/kotlin/com/tangem/domain/appcurrency/repository/AppCurrencyRepository.kt +++ b/domain/app-currency/src/main/kotlin/com/tangem/domain/appcurrency/repository/AppCurrencyRepository.kt @@ -5,9 +5,14 @@ import kotlinx.coroutines.flow.Flow interface AppCurrencyRepository { + /** + * Get Selected App Currency from cache or load from network. + */ fun getSelectedAppCurrency(): Flow suspend fun getAvailableAppCurrencies(): List suspend fun changeAppCurrency(currencyCode: String) + + suspend fun fetchDefaultAppCurrency(isRefresh: Boolean = false) } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardRepository.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardRepository.kt index 27bb1657ab..68e476443e 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardRepository.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/repository/CardRepository.kt @@ -7,4 +7,29 @@ interface CardRepository { fun wasCardScanned(cardId: String): Flow suspend fun setCardWasScanned(cardId: String) + + suspend fun startCardActivation(cardId: String) + + suspend fun finishCardActivation(cardId: String) + + suspend fun finishCardsActivation(cardIds: List) + + @Throws + suspend fun isActivationStarted(cardId: String): Boolean + + @Throws + suspend fun isActivationFinished(cardId: String): Boolean + + @Throws + suspend fun isActivationInProgress(cardId: String): Boolean + + @Throws + suspend fun isTangemTOSAccepted(): Boolean + + @Throws + suspend fun isStart2CoinTOSAccepted(cardId: String): Boolean + + suspend fun acceptTangemTOS() + + suspend fun acceptStart2CoinTOS(cardId: String) } \ No newline at end of file diff --git a/domain/legacy/build.gradle.kts b/domain/legacy/build.gradle.kts index 9a4c2225c2..bc0e716a7a 100644 --- a/domain/legacy/build.gradle.kts +++ b/domain/legacy/build.gradle.kts @@ -4,6 +4,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.domain.features" +} + dependencies { implementation(project(":core:datasource")) implementation(project(":core:utils")) diff --git a/domain/legacy/src/androidTest/java/com/tangem/domain/features/ExampleInstrumentedTest.kt b/domain/legacy/src/androidTest/java/com/tangem/domain/features/ExampleInstrumentedTest.kt deleted file mode 100644 index 70e3d0ada2..0000000000 --- a/domain/legacy/src/androidTest/java/com/tangem/domain/features/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.domain.features - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.tangem.feature2.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/domain/legacy/src/main/AndroidManifest.xml b/domain/legacy/src/main/AndroidManifest.xml deleted file mode 100644 index ea7f515204..0000000000 --- a/domain/legacy/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/extensions/Blockchain.kt b/domain/legacy/src/main/java/com/tangem/domain/common/extensions/Blockchain.kt index aca3f01cc2..03841cc507 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/extensions/Blockchain.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/extensions/Blockchain.kt @@ -87,6 +87,12 @@ fun Blockchain.Companion.fromNetworkId(networkId: String): Blockchain? { "algorand/test" -> Blockchain.AlgorandTestnet "hedera-hashgraph" -> Blockchain.Hedera "hedera-hashgraph/test" -> Blockchain.HederaTestnet + "aurora" -> Blockchain.Aurora + "aurora/test" -> Blockchain.AuroraTestnet + "areon-network" -> Blockchain.Areon + "areon-network/test" -> Blockchain.AreonTestnet + "pulsechain" -> Blockchain.PulseChain + "pulsechain/test" -> Blockchain.PulseChainTestnet else -> null } } @@ -175,6 +181,12 @@ fun Blockchain.toNetworkId(): String { Blockchain.AlgorandTestnet -> "algorand/test" Blockchain.Hedera -> "hedera-hashgraph" Blockchain.HederaTestnet -> "hedera-hashgraph/test" + Blockchain.Aurora -> "aurora" + Blockchain.AuroraTestnet -> "aurora/test" + Blockchain.Areon -> "areon-network" + Blockchain.AreonTestnet -> "areon-network/test" + Blockchain.PulseChain -> "pulsechain" + Blockchain.PulseChainTestnet -> "pulsechain/test" } } @@ -234,6 +246,9 @@ fun Blockchain.toCoinId(): String { Blockchain.Unknown -> "unknown" Blockchain.Hedera -> "hedera-hashgraph" Blockchain.HederaTestnet -> "hedera-hashgraph/test" + Blockchain.Aurora, Blockchain.AuroraTestnet -> "aurora-near" + Blockchain.Areon, Blockchain.AreonTestnet -> "areon-network" + Blockchain.PulseChain, Blockchain.PulseChainTestnet -> "pulsechain" } } @@ -252,7 +267,7 @@ fun Blockchain.amountToCreateAccount(token: Token? = null): BigDecimal? { } fun Blockchain.minimalAmount(): BigDecimal { - return 1.toBigDecimal().movePointLeft(decimals()) + return BigDecimal.ONE.movePointLeft(decimals()) } private const val NODL = "NODL" diff --git a/domain/legacy/src/main/java/com/tangem/domain/feedback/FeedbackManagerFeatureToggles.kt b/domain/legacy/src/main/java/com/tangem/domain/feedback/FeedbackManagerFeatureToggles.kt new file mode 100644 index 0000000000..2b259e17b6 --- /dev/null +++ b/domain/legacy/src/main/java/com/tangem/domain/feedback/FeedbackManagerFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.feedback + +interface FeedbackManagerFeatureToggles { + + val isLocalLogsEnabled: Boolean +} \ No newline at end of file diff --git a/domain/onboarding/.gitignore b/domain/onboarding/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/domain/onboarding/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/onboarding/build.gradle.kts b/domain/onboarding/build.gradle.kts new file mode 100644 index 0000000000..831b4eb4e4 --- /dev/null +++ b/domain/onboarding/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(deps.plugins.kotlin.jvm) + id("configuration") +} + +dependencies { + implementation(deps.arrow.core) + implementation(deps.kotlin.coroutines) +} \ No newline at end of file diff --git a/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/SaveTwinsOnboardingShownUseCase.kt b/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/SaveTwinsOnboardingShownUseCase.kt new file mode 100644 index 0000000000..fb6bf89e87 --- /dev/null +++ b/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/SaveTwinsOnboardingShownUseCase.kt @@ -0,0 +1,19 @@ +package com.tangem.domain.onboarding + +import com.tangem.domain.onboarding.repository.OnboardingRepository + +/** + * Saves that twins onboarding was shown + * + * @property onboardingRepository onboarding repository + * +[REDACTED_AUTHOR] + */ +class SaveTwinsOnboardingShownUseCase( + private val onboardingRepository: OnboardingRepository, +) { + + suspend operator fun invoke() { + onboardingRepository.saveTwinsOnboardingShown() + } +} \ No newline at end of file diff --git a/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/WasTwinsOnboardingShownUseCase.kt b/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/WasTwinsOnboardingShownUseCase.kt new file mode 100644 index 0000000000..9666714871 --- /dev/null +++ b/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/WasTwinsOnboardingShownUseCase.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.onboarding + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.domain.onboarding.repository.OnboardingRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +/** + * Use case for checking if twins onboarding was shown + * + * @property onboardingRepository onboarding repository + * +[REDACTED_AUTHOR] + */ +class WasTwinsOnboardingShownUseCase( + private val onboardingRepository: OnboardingRepository, +) { + + /** Get flow with twins onboarding state or error [Throwable] */ + operator fun invoke(): Flow> { + return onboardingRepository.wasTwinsOnboardingShown() + .map(Boolean::right) + .catch { it.left() } + } + + /** Get twins onboarding state synchronously or default value if exception will be thrown */ + suspend fun invokeSync(): Boolean { + return runCatching { onboardingRepository.wasTwinsOnboardingShownSync() }.getOrDefault(defaultValue = false) + } +} \ No newline at end of file diff --git a/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/repository/OnboardingRepository.kt b/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/repository/OnboardingRepository.kt new file mode 100644 index 0000000000..cda4f3b19a --- /dev/null +++ b/domain/onboarding/src/main/kotlin/com/tangem/domain/onboarding/repository/OnboardingRepository.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.onboarding.repository + +import kotlinx.coroutines.flow.Flow + +/** + * Onboarding repository + * +[REDACTED_AUTHOR] + */ +interface OnboardingRepository { + + fun wasTwinsOnboardingShown(): Flow + + @Throws + suspend fun wasTwinsOnboardingShownSync(): Boolean + + suspend fun saveTwinsOnboardingShown() +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/models/AppLogsModel.kt b/domain/settings/src/main/java/com/tangem/domain/settings/models/AppLogsModel.kt new file mode 100644 index 0000000000..746974930f --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/models/AppLogsModel.kt @@ -0,0 +1,3 @@ +package com.tangem.domain.settings.models + +data class AppLogsModel(val timestamp: Long, val message: String) \ 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 7dd4507554..f686561f3e 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 @@ -1,5 +1,7 @@ package com.tangem.domain.settings.repositories +import com.tangem.domain.settings.models.AppLogsModel + interface SettingsRepository { suspend fun shouldShowSaveUserWalletScreen(): Boolean @@ -7,4 +9,13 @@ interface SettingsRepository { suspend fun isWalletScrollPreviewEnabled(): Boolean suspend fun setWalletScrollPreviewAvailability(isEnabled: Boolean) + + @Throws + suspend fun getAppLogs(): List + + @Throws + suspend fun updateAppLogs(message: String) + + @Throws + suspend fun deleteDeprecatedLogs() } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/FeePaidCurrency.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/FeePaidCurrency.kt index 50945df4f9..dba9b23181 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/FeePaidCurrency.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/FeePaidCurrency.kt @@ -3,8 +3,8 @@ package com.tangem.domain.tokens.model import java.math.BigDecimal sealed class FeePaidCurrency { - object Coin : FeePaidCurrency() - object SameCurrency : FeePaidCurrency() + data object Coin : FeePaidCurrency() + data object SameCurrency : FeePaidCurrency() data class Token( val tokenId: CryptoCurrency.ID, val name: String, diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt index b90d787043..6e477d8a10 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/CryptoCurrencyWarning.kt @@ -24,7 +24,7 @@ sealed class CryptoCurrencyWarning { val feeCurrencySymbol: String, ) : CryptoCurrencyWarning() - object SomeNetworksUnreachable : CryptoCurrencyWarning() + data object SomeNetworksUnreachable : CryptoCurrencyWarning() data class SomeNetworksNoAccount( val amountToCreateAccount: BigDecimal, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt index 1ead96bd8a..f0c73552f7 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetCryptoCurrenciesUseCase.kt @@ -1,16 +1,27 @@ package com.tangem.domain.tokens import arrow.core.Either +import arrow.core.left import arrow.core.raise.catch import arrow.core.raise.either +import arrow.core.right import com.tangem.domain.tokens.error.GetCurrenciesError import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map class GetCryptoCurrenciesUseCase(private val currenciesRepository: CurrenciesRepository) { - suspend operator fun invoke( + operator fun invoke(userWalletId: UserWalletId): Flow>> { + return currenciesRepository.getMultiCurrencyWalletCurrenciesUpdates(userWalletId) + .map, Either>> { it.right() } + .catch { emit(GetCurrenciesError.DataError(it).left()) } + } + + suspend fun getSync( userWalletId: UserWalletId, refresh: Boolean = false, ): Either> { diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt index 36e147306c..7b43c6221f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/GetFeePaidCryptoCurrencyStatusSyncUseCase.kt @@ -35,14 +35,14 @@ class GetFeePaidCryptoCurrencyStatusSyncUseCase( return either { when (feePaidCurrency) { - FeePaidCurrency.Coin -> getCryptoCurrency(operations, cryptoCurrency.id) + FeePaidCurrency.Coin -> getCryptoCurrencyStatus(operations, cryptoCurrency.id) FeePaidCurrency.SameCurrency -> cryptoCurrencyStatus - is FeePaidCurrency.Token -> getCryptoCurrency(operations, feePaidCurrency.tokenId) + is FeePaidCurrency.Token -> getCryptoCurrencyStatus(operations, feePaidCurrency.tokenId) } } } - private suspend fun getCryptoCurrency( + private suspend fun getCryptoCurrencyStatus( operations: CurrenciesStatusesOperations, cryptoCurrencyId: CryptoCurrency.ID, ): CryptoCurrencyStatus? { diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/UpdateDelayedNetworkStatusUseCase.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/UpdateDelayedNetworkStatusUseCase.kt index 1991a33bf8..26d641497a 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/UpdateDelayedNetworkStatusUseCase.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/UpdateDelayedNetworkStatusUseCase.kt @@ -33,7 +33,7 @@ class UpdateDelayedNetworkStatusUseCase( suspend operator fun invoke( userWalletId: UserWalletId, network: Network, - delayMillis: Long, + delayMillis: Long = 0L, refresh: Boolean = false, ): Either { delay(delayMillis) diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepository.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepository.kt index 381d469f98..b010a54db4 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepository.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/repository/TxHistoryRepository.kt @@ -23,4 +23,12 @@ interface TxHistoryRepository { ): Flow> fun getTxExploreUrl(txHash: String, networkId: Network.ID): String + + @Throws(TxHistoryListError::class) + suspend fun getFixedSizeTxHistoryItems( + userWalletId: UserWalletId, + currency: CryptoCurrency, + pageSize: Int, + refresh: Boolean, + ): List } \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetFixedTxHistoryItemsUseCase.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetFixedTxHistoryItemsUseCase.kt new file mode 100644 index 0000000000..aa93bec246 --- /dev/null +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetFixedTxHistoryItemsUseCase.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.txhistory.usecase + +import arrow.core.Either +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.domain.txhistory.models.TxHistoryItem +import com.tangem.domain.txhistory.models.TxHistoryListError +import com.tangem.domain.txhistory.repository.TxHistoryRepository +import com.tangem.domain.wallets.models.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Returns first page with size [DEFAULT_PAGE_SIZE] of tx history. + * Page size is preserved to reuse cached data in Token Details Screen. + * + * IMPORTANT!!! + * If page size bigger than [DEFAULT_PAGE_SIZE] is needed consider to implement another use case + * without use of cached data or increase [DEFAULT_PAGE_SIZE] + */ +class GetFixedTxHistoryItemsUseCase( + private val repository: TxHistoryRepository, +) { + + operator fun invoke( + userWalletId: UserWalletId, + currency: CryptoCurrency, + pageSize: Int = DEFAULT_PAGE_SIZE, + refresh: Boolean = false, + ): Either>> { + return Either.catch { + flow { + emit(repository.getFixedSizeTxHistoryItems(userWalletId, currency, pageSize, refresh)) + } + }.mapLeft { TxHistoryListError.DataError(it) } + } +} \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetTxHistoryItemsUseCase.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetTxHistoryItemsUseCase.kt index e5570e8077..cb6724fc60 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetTxHistoryItemsUseCase.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/usecase/GetTxHistoryItemsUseCase.kt @@ -11,7 +11,7 @@ import com.tangem.domain.wallets.models.UserWalletId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch -private const val DEFAULT_PAGE_SIZE = 50 +const val DEFAULT_PAGE_SIZE = 50 // TODO: Add tests class GetTxHistoryItemsUseCase(private val repository: TxHistoryRepository) { diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/legacy/UserWalletsListManagerFeatureToggles.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/legacy/UserWalletsListManagerFeatureToggles.kt new file mode 100644 index 0000000000..d5af7e4449 --- /dev/null +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/legacy/UserWalletsListManagerFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.wallets.legacy + +interface UserWalletsListManagerFeatureToggles { + + val isGeneralManagerEnabled: Boolean +} \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/navigation/ExpandableState.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/navigation/ExpandableState.kt new file mode 100644 index 0000000000..34048e6f11 --- /dev/null +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/navigation/ExpandableState.kt @@ -0,0 +1,6 @@ +package com.tangem.features.managetokens.navigation + +enum class ExpandableState { + EXPANDED, + COLLAPSED, +} \ No newline at end of file diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/navigation/ManageTokensUi.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/navigation/ManageTokensUi.kt index 7e5c95da12..f200ab6a3c 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/navigation/ManageTokensUi.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/navigation/ManageTokensUi.kt @@ -1,10 +1,12 @@ package com.tangem.features.managetokens.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.ui.unit.Dp interface ManageTokensUi { + @Suppress("TopLevelComposableFunctions") @Composable - fun Content(onHeaderSizeChange: (Dp) -> Unit) + fun Content(onHeaderSizeChange: (Dp) -> Unit, state: State) } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/addcustomtoken/ui/AddCustomTokenBottomSheet.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/addcustomtoken/ui/AddCustomTokenBottomSheet.kt index 499c2e6cb4..69fe567327 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/addcustomtoken/ui/AddCustomTokenBottomSheet.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/addcustomtoken/ui/AddCustomTokenBottomSheet.kt @@ -1,13 +1,22 @@ package com.tangem.managetokens.presentation.addcustomtoken.ui import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetDefaults +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -15,8 +24,18 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -29,6 +48,7 @@ import com.tangem.managetokens.presentation.addcustomtoken.router.AddCustomToken import com.tangem.managetokens.presentation.addcustomtoken.router.AddCustomTokenRouter import com.tangem.managetokens.presentation.addcustomtoken.viewmodels.AddCustomTokenViewModel import com.tangem.managetokens.presentation.common.state.ChooseWalletState +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -46,14 +66,14 @@ fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig) { onDispose { viewModel.onDispose() } } - // FIXME: handle back presses after updating material3 to 1.2.0 - ModalBottomSheet( + ModalBottomSheetWithBackHandling( onDismissRequest = config.onDismissRequest, sheetState = sheetState, containerColor = TangemTheme.colors.background.tertiary, shape = TangemTheme.shapes.bottomSheetLarge, windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top), dragHandle = { TangemBottomSheetDraggableHeader(color = TangemTheme.colors.background.tertiary) }, + properties = ModalBottomSheetDefaults.properties(shouldDismissOnBackPress = false), ) { Content(onDismissRequest = config.onDismissRequest, viewModel = viewModel) } @@ -68,6 +88,68 @@ fun AddCustomTokenBottomSheet(config: TangemBottomSheetConfig) { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModalBottomSheetWithBackHandling( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + containerColor: Color = BottomSheetDefaults.ContainerColor, + shape: Shape = BottomSheetDefaults.ExpandedShape, + windowInsets: WindowInsets = BottomSheetDefaults.windowInsets, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + sheetState: SheetState = rememberModalBottomSheetState(), + properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties(), + content: @Composable ColumnScope.() -> Unit, +) { + val scope = rememberCoroutineScope() + + BackHandler(enabled = sheetState.targetValue != SheetValue.Hidden) { + // Always catch back here, but only let it dismiss if shouldDismissOnBackPress. + // If not, it will have no effect. + if (properties.shouldDismissOnBackPress) { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + onDismissRequest() + } + } + } + } + + val requester = remember { FocusRequester() } + val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + containerColor = containerColor, + shape = shape, + windowInsets = windowInsets, + dragHandle = dragHandle, + sheetState = sheetState, + modifier = modifier + .focusRequester(requester) + .focusable() + .onPreviewKeyEvent { + if (it.key == Key.Back && it.type == KeyEventType.KeyUp && !it.nativeKeyEvent.isCanceled) { + backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed() + return@onPreviewKeyEvent true + } + return@onPreviewKeyEvent false + }, + properties = ModalBottomSheetDefaults.properties( + securePolicy = properties.securePolicy, + isFocusable = properties.isFocusable, + // Set false otherwise the onPreviewKeyEvent doesn't work at all. + // The functionality of shouldDismissOnBackPress is achieved by the BackHandler. + shouldDismissOnBackPress = false, + ), + content = content, + ) + + LaunchedEffect(Unit) { + requester.requestFocus() + } +} + @SuppressLint("RestrictedApi") @Composable private fun Content(viewModel: AddCustomTokenViewModel, onDismissRequest: () -> Unit) { @@ -82,6 +164,10 @@ private fun Content(viewModel: AddCustomTokenViewModel, onDismissRequest: () -> } } + BackHandler(true) { + navController.popBackStack() + } + val router = remember(navController) { AddCustomTokenRouter(navController) } viewModel.router = router diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/addcustomtoken/viewmodels/AddCustomTokenViewModel.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/addcustomtoken/viewmodels/AddCustomTokenViewModel.kt index 1efdf2911b..1b107076ea 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/addcustomtoken/viewmodels/AddCustomTokenViewModel.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/addcustomtoken/viewmodels/AddCustomTokenViewModel.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import arrow.core.getOrElse @@ -105,10 +104,6 @@ internal class AddCustomTokenViewModel @Inject constructor( uiState = stateFactory.getInitialState() } - override fun onDestroy(owner: LifecycleOwner) { - uiState = stateFactory.getInitialState() - } - private suspend fun selectSuitableWallet(suitableUserWallets: List): UserWalletId? { val selectedWallet = getSelectedWalletSyncUseCase().getOrNull() val selectedWalletId = if (walletSupportsAddingTokens(selectedWallet) && suitableUserWallets.isNotEmpty()) { @@ -424,7 +419,7 @@ internal class AddCustomTokenViewModel @Inject constructor( selectedWallet: UserWallet, cryptoCurrency: CryptoCurrency, ): Boolean { - val currenciesList = getCurrenciesUseCase(selectedWallet.walletId).getOrElse { emptyList() } + val currenciesList = getCurrenciesUseCase.getSync(selectedWallet.walletId).getOrElse { emptyList() } return when (cryptoCurrency) { is CryptoCurrency.Coin -> { currenciesList.any { diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/QuotesState.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/QuotesState.kt index 9e255ebc77..3518626707 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/QuotesState.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/QuotesState.kt @@ -1,5 +1,6 @@ package com.tangem.managetokens.presentation.managetokens.state +import com.tangem.core.ui.components.marketprice.PriceChangeType import kotlinx.collections.immutable.ImmutableList internal sealed class QuotesState { @@ -10,8 +11,4 @@ internal sealed class QuotesState { val changeType: PriceChangeType, val chartData: ImmutableList, ) : QuotesState() -} - -enum class PriceChangeType { - UP, DOWN } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/factory/QuotesToQuotesStateConverter.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/factory/QuotesToQuotesStateConverter.kt index b9559d3a9c..972c004779 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/factory/QuotesToQuotesStateConverter.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/state/factory/QuotesToQuotesStateConverter.kt @@ -1,8 +1,9 @@ package com.tangem.managetokens.presentation.managetokens.state.factory +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.tokens.model.Quote -import com.tangem.managetokens.presentation.managetokens.state.PriceChangeType import com.tangem.managetokens.presentation.managetokens.state.QuotesState import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf @@ -16,19 +17,18 @@ internal class QuotesToQuotesStateConverter : Converter { priceChange = BigDecimalFormatter.formatPercent( percent = priceChange.movePointLeft(2), useAbsoluteValue = true, - maxFractionDigits = 1, - minFractionDigits = 1, ), changeType = priceChange.getPriceChangeType(), chartData = // TODO (in [REDACTED_TASK_KEY] when endpoint is ready) when (priceChange.getPriceChangeType()) { PriceChangeType.UP -> persistentListOf(0f, 5f, 10f, 30f) PriceChangeType.DOWN -> persistentListOf(15f, 12f, 13f, 18f, 10f, 3f) + PriceChangeType.NEUTRAL -> persistentListOf(0f, 0f, 0f, 0f) }, ) } private fun BigDecimal.getPriceChangeType(): PriceChangeType { - return if (this >= BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN + return PriceChangeConverter.fromBigDecimal(value = this) } } \ 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 index 40d4334eb3..eab841b41f 100644 --- 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 @@ -5,7 +5,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent 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.* import com.tangem.managetokens.presentation.managetokens.state.ManageTokensState import com.tangem.managetokens.presentation.managetokens.state.SearchBarState import com.tangem.managetokens.presentation.managetokens.state.TokenItemState @@ -32,6 +32,7 @@ internal object ManageTokensStatePreviewData { get() = listOf( TokenItemStatePreviewData.loadedPriceDown, TokenItemStatePreviewData.loadedPriceUp, + TokenItemStatePreviewData.loadedPriceNeutral, ) private val searchState: SearchBarState 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 01ed9bdda9..1ba8f760e6 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 @@ -2,7 +2,11 @@ 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 com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.managetokens.presentation.managetokens.state.QuotesState +import com.tangem.managetokens.presentation.managetokens.state.TokenButtonType +import com.tangem.managetokens.presentation.managetokens.state.TokenIconState +import com.tangem.managetokens.presentation.managetokens.state.TokenItemState import kotlinx.collections.immutable.persistentListOf internal object TokenItemStatePreviewData { @@ -46,6 +50,24 @@ internal object TokenItemStatePreviewData { chooseNetworkState = ChooseNetworkStatePreviewData.state, ) + val loadedPriceNeutral: TokenItemState + get() = TokenItemState.Loaded( + id = "BTC", + name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", + tokenId = "BTC", + currencySymbol = "BTC", + tokenIcon = tokenIconState, + quotes = QuotesState.Content( + priceChange = "0.00%", + changeType = PriceChangeType.NEUTRAL, + chartData = persistentListOf(10f, 2f, 5f, 3f, 4f, 8f, 9f, 7f, 10f), + ), + rate = "31 285.72$", + availableAction = mutableStateOf(TokenButtonType.ADD), + onButtonClick = {}, + chooseNetworkState = ChooseNetworkStatePreviewData.state, + ) + private val tokenIconState: TokenIconState get() = TokenIconState( iconReference = null, diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/PriceChangesChart.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/PriceChangesChart.kt index 9909709a8d..998dbebc72 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/PriceChangesChart.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/PriceChangesChart.kt @@ -120,4 +120,14 @@ private fun Chart_Negative_Preview() { persistentListOf(10f, 2f, 4f, 1f, 5f), ) } +} + +@Preview(widthDp = 150, heightDp = 150, showBackground = true) +@Composable +private fun Chart_Neutral_Preview() { + TangemTheme(isDark = true) { + PriceChangesChart( + persistentListOf(5f, 2f, 4f, 1f, 5f), + ) + } } \ No newline at end of file diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokenPriceChange.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokenPriceChange.kt index 1b2d04c7a9..28b7691794 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokenPriceChange.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/managetokens/ui/components/TokenPriceChange.kt @@ -11,9 +11,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.res.TangemTheme import com.tangem.features.managetokens.impl.R -import com.tangem.managetokens.presentation.managetokens.state.PriceChangeType import com.tangem.managetokens.presentation.managetokens.state.QuotesState @Composable @@ -48,11 +48,13 @@ private fun PriceChangeIcon(type: PriceChangeType?) { id = when (animatedType) { PriceChangeType.UP -> R.drawable.ic_arrow_up_8 PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8 + PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8 }, ), tint = when (animatedType) { PriceChangeType.UP -> TangemTheme.colors.icon.accent PriceChangeType.DOWN -> TangemTheme.colors.icon.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive }, contentDescription = null, ) @@ -69,6 +71,7 @@ private fun PriceChangeText(type: PriceChangeType?, text: String?) { color = when (type) { PriceChangeType.UP -> TangemTheme.colors.text.accent PriceChangeType.DOWN -> TangemTheme.colors.text.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors.text.disabled null -> TangemTheme.colors.text.primary1 }, overflow = TextOverflow.Ellipsis, 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 12a403e3e4..d92f0a8b81 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 @@ -180,7 +180,8 @@ private fun BaseSurface(modifier: Modifier = Modifier, content: @Composable () - } } -@Preview() +// region Preview +@Preview(showBackground = true, widthDp = 360) @Composable private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) { TangemTheme(isDark = false) { @@ -188,7 +189,7 @@ private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::cla } } -@Preview +@Preview(showBackground = true, widthDp = 360) @Composable private fun Preview_Tokens_DarkTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) { TangemTheme(isDark = true) { @@ -201,5 +202,7 @@ private class TokenConfigProvider : CollectionPreviewParameterProvider = mutableListOf() + private var expandableState: ExpandableState = ExpandableState.COLLAPSED - private var wallets: List by Delegates.notNull() + private val currenciesListJobHolder: JobHolder = JobHolder() - private var addedCurrenciesByWallet: MutableMap> = mutableMapOf() + private val debouncer = Debouncer() + + private var allAddedCurrencies: MutableList = Collections.synchronizedList( + mutableListOf(), + ) + + private var wallets: CopyOnWriteArrayList by Delegates.notNull() + + private var addedCurrenciesByWallet: MutableMap> = ConcurrentHashMap() private var selectedWallet: UserWallet? = null @@ -114,34 +123,59 @@ internal class ManageTokensViewModel @Inject constructor( getWalletsUseCase() .distinctUntilChanged() .collectLatest { userWallets -> - wallets = userWallets.filter { it.isMultiCurrency && !it.isLocked } - wallets.map { wallet -> - val currencies = getCurrenciesUseCase(wallet.walletId).fold( - ifLeft = { emptyList() }, - ifRight = { it }, - ) - allAddedCurrencies += currencies - addedCurrenciesByWallet[wallet] = currencies.toMutableList() - } - withContext(dispatchers.main) { - uiState = uiState.copy(tokens = getInitialTokensList()) - } - selectedWallet = getSelectedWalletSyncUseCase().fold( - ifLeft = { null }, - ifRight = { if (!it.isMultiCurrency || it.isLocked) null else it }, - ) - if (selectedWallet == null && wallets.isNotEmpty()) { - selectWalletUseCase(wallets.first().walletId) - selectedWallet = wallets.first() - } - updateDerivationNotificationState() - withContext(dispatchers.main) { - uiState = stateFactory.updateChooseWalletState(wallets, userWallets, selectedWallet) - } + launch { + subscribeToCurrencies(userWallets) + }.saveIn(currenciesListJobHolder) } } } + fun setExpandableState(state: State) { + expandableState = state.value + } + + private suspend fun subscribeToCurrencies(userWallets: List) { + wallets = CopyOnWriteArrayList(userWallets.filter { it.isMultiCurrency && !it.isLocked }) + + combine(wallets.map { getCurrenciesUseCase.invoke(it.walletId).distinctUntilChanged() }) { + if (expandableState == ExpandableState.EXPANDED) return@combine + + allAddedCurrencies.clear() + addedCurrenciesByWallet.clear() + + val walletsWithCurrencies = wallets.zip( + it.map { currencyList -> + currencyList.getOrElse { + Timber.e("Couldn't retrieve currency list") + emptyList() + } + }, + ) + + allAddedCurrencies = walletsWithCurrencies.flatMap { it.second }.toMutableList() + + walletsWithCurrencies.forEach { (wallet, currencies) -> + addedCurrenciesByWallet[wallet] = currencies.toMutableList() + } + + withContext(dispatchers.main) { + uiState = uiState.copy(tokens = getInitialTokensList()) + } + selectedWallet = getSelectedWalletSyncUseCase().fold( + ifLeft = { null }, + ifRight = { if (!it.isMultiCurrency || it.isLocked) null else it }, + ) + if (selectedWallet == null && wallets.isNotEmpty()) { + selectWalletUseCase(wallets.first().walletId) + selectedWallet = wallets.first() + } + updateDerivationNotificationState() + withContext(dispatchers.main) { + uiState = stateFactory.updateChooseWalletState(wallets, userWallets, selectedWallet) + } + }.collect() + } + private fun getInitialTokensList(searchText: String = ""): Flow> { return getGlobalTokenListUseCase(searchText = searchText).map { it.map { token -> tokenConverter.convert(token) } diff --git a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/router/ManageTokensUiImpl.kt b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/router/ManageTokensUiImpl.kt index 3dd460d06f..9bcf70872f 100644 --- a/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/router/ManageTokensUiImpl.kt +++ b/features/manage-tokens/impl/src/main/java/com/tangem/managetokens/presentation/router/ManageTokensUiImpl.kt @@ -1,8 +1,10 @@ package com.tangem.managetokens.presentation.router import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.ui.unit.Dp import androidx.hilt.navigation.compose.hiltViewModel +import com.tangem.features.managetokens.navigation.ExpandableState import com.tangem.features.managetokens.navigation.ManageTokensUi import com.tangem.managetokens.presentation.managetokens.ui.ManageTokensScreen import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensViewModel @@ -11,8 +13,9 @@ import javax.inject.Inject internal class ManageTokensUiImpl @Inject constructor() : ManageTokensUi { @Composable - override fun Content(onHeaderSizeChange: (Dp) -> Unit) { + override fun Content(onHeaderSizeChange: (Dp) -> Unit, state: State) { val viewModel = hiltViewModel() + viewModel.setExpandableState(state) ManageTokensScreen( state = viewModel.uiState, diff --git a/features/onboarding/build.gradle.kts b/features/onboarding/build.gradle.kts index 8318108615..6629611e63 100644 --- a/features/onboarding/build.gradle.kts +++ b/features/onboarding/build.gradle.kts @@ -7,6 +7,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.feature.onboarding" +} + dependencies { /** Core modules */ implementation(project(":common")) diff --git a/features/onboarding/src/main/AndroidManifest.xml b/features/onboarding/src/main/AndroidManifest.xml deleted file mode 100644 index 21015a0f53..0000000000 --- a/features/onboarding/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/features/qr-scanning/impl/build.gradle.kts b/features/qr-scanning/impl/build.gradle.kts index 712e2a79ce..e1d37506b4 100644 --- a/features/qr-scanning/impl/build.gradle.kts +++ b/features/qr-scanning/impl/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(deps.androidx.fragment.ktx) implementation(deps.androidx.activity.compose) + implementation(deps.lifecycle.compose) /** Camera */ implementation(deps.camera.camera2) diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/QrScanningFragment.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/QrScanningFragment.kt index cec4314b37..41a8b85226 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/QrScanningFragment.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/QrScanningFragment.kt @@ -2,6 +2,7 @@ package com.tangem.feature.qrscanning import android.Manifest import android.content.pm.PackageManager +import android.net.Uri import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable @@ -13,14 +14,16 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.google.mlkit.vision.common.InputImage import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.screen.ComposeFragment import com.tangem.core.ui.theme.AppThemeModeHolder -import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer -import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter import com.tangem.feature.qrscanning.presentation.QrScanningContent import com.tangem.feature.qrscanning.viewmodel.QrScanningViewModel +import com.tangem.feature.qrscanning.inner.MLKitBarcodeAnalyzer +import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter import dagger.hilt.android.AndroidEntryPoint import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -49,12 +52,19 @@ internal class QrScanningFragment : ComposeFragment() { } private val cameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (!it) parentFragmentManager.popBackStack() + if (!it) viewModel.onCameraDeniedState() + } + private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { + val selectedImage = it ?: Uri.EMPTY + if (selectedImage != Uri.EMPTY) { + val image = InputImage.fromFilePath(requireContext(), selectedImage) + analyzer.analyze(image) + } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.router = innerRouter + viewModel.setRouter(innerRouter, galleryLauncher) cameraExecutor = Executors.newSingleThreadExecutor() requestCameraPermission() } @@ -71,7 +81,7 @@ internal class QrScanningFragment : ComposeFragment() { QrScanningContent( executor = { cameraExecutor }, analyzer = { analyzer }, - uiState = viewModel.uiState, + uiState = viewModel.uiState.collectAsStateWithLifecycle().value, ) } diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/CameraDeniedBottomSheetConfig.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/CameraDeniedBottomSheetConfig.kt new file mode 100644 index 0000000000..3d23eb1813 --- /dev/null +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/CameraDeniedBottomSheetConfig.kt @@ -0,0 +1,8 @@ +package com.tangem.feature.qrscanning.presentation + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +data class CameraDeniedBottomSheetConfig( + val onGalleryClick: () -> Unit, + val onCancelClick: () -> Unit, +) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningCameraDeniedBottomSheet.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningCameraDeniedBottomSheet.kt new file mode 100644 index 0000000000..a4ca7198dc --- /dev/null +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningCameraDeniedBottomSheet.kt @@ -0,0 +1,82 @@ +package com.tangem.feature.qrscanning.presentation + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import com.tangem.core.ui.components.SimpleSettingsRow +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.qrscanning.impl.R + +@Composable +fun CameraDeniedBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet(config) { content: CameraDeniedBottomSheetConfig -> + CameraDeniedBottomSheet(content = content) + } +} + +@Composable +private fun CameraDeniedBottomSheet(content: CameraDeniedBottomSheetConfig) { + val context = LocalContext.current + Column { + CameraDeniedBottomSheetHeader() + SimpleSettingsRow( + title = stringResource(id = R.string.qr_scanner_camera_denied_settings_button), + icon = R.drawable.ic_settings_24, + onItemsClick = { + val intent: Intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ContextCompat.startActivity(context, intent, null) + }, + ) + SimpleSettingsRow( + title = stringResource(id = R.string.qr_scanner_camera_denied_gallery_button), + icon = R.drawable.ic_gallery_24, + onItemsClick = content.onGalleryClick, + ) + SimpleSettingsRow( + title = stringResource(id = R.string.common_close), + icon = R.drawable.ic_close, + onItemsClick = content.onCancelClick, + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing16), + ) + } +} + +@Composable +private fun CameraDeniedBottomSheetHeader() { + Text( + text = stringResource(id = R.string.qr_scanner_camera_denied_title), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing20, + end = TangemTheme.dimens.spacing20, + top = TangemTheme.dimens.spacing16, + ), + ) + Text( + text = stringResource(id = R.string.qr_scanner_camera_denied_text), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing20, + end = TangemTheme.dimens.spacing20, + top = TangemTheme.dimens.spacing3, + bottom = TangemTheme.dimens.spacing16, + ), + ) +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt index 8d169703a6..6dc9d3592b 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningContent.kt @@ -1,9 +1,10 @@ package com.tangem.feature.qrscanning.presentation -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.* +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -18,14 +19,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Constraints -import com.google.mlkit.vision.common.InputImage import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIconContent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference @@ -44,15 +43,9 @@ internal fun QrScanningContent( analyzer: () -> MLKitBarcodeAnalyzer, uiState: QrScanningState, ) { - val context = LocalContext.current var isFlash by remember { mutableStateOf(false) } - val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { - val selectedImage = it ?: Uri.EMPTY - if (selectedImage != Uri.EMPTY) { - val image = InputImage.fromFilePath(context, selectedImage) - analyzer().analyze(image) - } - } + + BackHandler(onBack = uiState.onBackClick) Box( modifier = Modifier @@ -101,13 +94,16 @@ internal fun QrScanningContent( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(bounded = false), - onClick = { galleryLauncher.launch("image/*") }, + onClick = uiState.onGalleryClick, ), tint = TangemColorPalette.White, ) } }, ) + if (uiState.bottomSheetConfig != null) { + CameraDeniedBottomSheet(uiState.bottomSheetConfig) + } } } diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningState.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningState.kt index d9eb27b155..e9ca30f4e1 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningState.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningState.kt @@ -1,6 +1,7 @@ package com.tangem.feature.qrscanning.presentation import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.TextReference @Immutable @@ -8,5 +9,6 @@ data class QrScanningState( val message: TextReference?, val onQrScanned: (String) -> Unit, val onBackClick: () -> Unit, - val onGalleryClicked: () -> Unit, + val onGalleryClick: () -> Unit, + val bottomSheetConfig: TangemBottomSheetConfig? = null, ) \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningStateController.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningStateController.kt new file mode 100644 index 0000000000..efad8e7984 --- /dev/null +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningStateController.kt @@ -0,0 +1,35 @@ +package com.tangem.feature.qrscanning.presentation + +import com.tangem.feature.qrscanning.presentation.transformers.QrScanningTransformer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class QrScanningStateController @Inject constructor() { + + val uiState: StateFlow get() = mutableUiState + + val value: QrScanningState get() = uiState.value + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + fun update(function: (QrScanningState) -> QrScanningState) { + mutableUiState.update(function = function) + } + + fun update(transformer: QrScanningTransformer) { + mutableUiState.update(function = transformer::transform) + } + + private fun getInitialState(): QrScanningState { + return QrScanningState( + message = null, + onBackClick = {}, + onQrScanned = {}, + onGalleryClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/DismissBottomSheetTransformer.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/DismissBottomSheetTransformer.kt new file mode 100644 index 0000000000..bd3d2fb621 --- /dev/null +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/DismissBottomSheetTransformer.kt @@ -0,0 +1,11 @@ +package com.tangem.feature.qrscanning.presentation.transformers + +import com.tangem.feature.qrscanning.presentation.QrScanningState + +internal class DismissBottomSheetTransformer : QrScanningTransformer { + override fun transform(prevState: QrScanningState): QrScanningState { + return prevState.copy( + bottomSheetConfig = null, + ) + } +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningStateFactory.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt similarity index 57% rename from features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningStateFactory.kt rename to features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt index 0b7ef2c4da..c4df653e5a 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/QrScanningStateFactory.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt @@ -1,16 +1,19 @@ -package com.tangem.feature.qrscanning.presentation +package com.tangem.feature.qrscanning.presentation.transformers import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents import com.tangem.feature.qrscanning.SourceType import com.tangem.feature.qrscanning.impl.R +import com.tangem.feature.qrscanning.presentation.QrScanningState +import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents -internal class QrScanningStateFactory( - val clickIntents: QrScanningClickIntents, -) { +internal class InitializeQrScanningStateTransformer( + private val clickIntents: QrScanningClickIntents, + private val source: SourceType, + private val network: String?, +) : QrScanningTransformer { - fun getInitialState(source: SourceType, network: String?): QrScanningState { + override fun transform(prevState: QrScanningState): QrScanningState { val message = when (source) { SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) } else -> null @@ -20,7 +23,7 @@ internal class QrScanningStateFactory( message = message, onBackClick = clickIntents::onBackClick, onQrScanned = clickIntents::onQrScanned, - onGalleryClicked = clickIntents::onGalleryClicked, + onGalleryClick = clickIntents::onGalleryClicked, ) } } \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/QrScanningTransformer.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/QrScanningTransformer.kt new file mode 100644 index 0000000000..16dcdfbd77 --- /dev/null +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/QrScanningTransformer.kt @@ -0,0 +1,8 @@ +package com.tangem.feature.qrscanning.presentation.transformers + +import com.tangem.feature.qrscanning.presentation.QrScanningState + +internal interface QrScanningTransformer { + + fun transform(prevState: QrScanningState): QrScanningState +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/ShowCameraDeniedBottomSheetTransformer.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/ShowCameraDeniedBottomSheetTransformer.kt new file mode 100644 index 0000000000..5677a023f8 --- /dev/null +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/ShowCameraDeniedBottomSheetTransformer.kt @@ -0,0 +1,24 @@ +package com.tangem.feature.qrscanning.presentation.transformers + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.feature.qrscanning.presentation.CameraDeniedBottomSheetConfig +import com.tangem.feature.qrscanning.presentation.QrScanningState +import com.tangem.feature.qrscanning.viewmodel.QrScanningClickIntents + +internal class ShowCameraDeniedBottomSheetTransformer( + private val clickIntents: QrScanningClickIntents, +) : QrScanningTransformer { + + override fun transform(prevState: QrScanningState): QrScanningState { + return prevState.copy( + bottomSheetConfig = TangemBottomSheetConfig( + isShow = true, + onDismissRequest = clickIntents::onBackClick, + content = CameraDeniedBottomSheetConfig( + onCancelClick = clickIntents::onBackClick, + onGalleryClick = clickIntents::onGalleryClicked, + ), + ), + ) + } +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/BaseQrScanningClickIntents.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/BaseQrScanningClickIntents.kt new file mode 100644 index 0000000000..3ae564a745 --- /dev/null +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/BaseQrScanningClickIntents.kt @@ -0,0 +1,33 @@ +package com.tangem.feature.qrscanning.viewmodel + +import androidx.activity.result.ActivityResultLauncher +import com.tangem.feature.qrscanning.SourceType +import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter +import kotlinx.coroutines.CoroutineScope +import kotlin.properties.Delegates + +internal open class BaseQrScanningClickIntents { + + protected val router: QrScanningInnerRouter get() = _router + protected val viewModelScope: CoroutineScope get() = _viewModelScope + protected val source: SourceType get() = _source + protected val galleryLauncher: ActivityResultLauncher get() = _galleryLauncher + + private var _router: QrScanningInnerRouter by Delegates.notNull() + private var _viewModelScope: CoroutineScope by Delegates.notNull() + private var _source: SourceType by Delegates.notNull() + + private var _galleryLauncher: ActivityResultLauncher by Delegates.notNull() + + open fun initialize( + router: QrScanningInnerRouter, + source: SourceType, + galleryLauncher: ActivityResultLauncher, + coroutineScope: CoroutineScope, + ) { + _router = router + _viewModelScope = coroutineScope + _source = source + _galleryLauncher = galleryLauncher + } +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/QrScanningClickIntents.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/QrScanningClickIntents.kt index 38219460a6..7ecbdae562 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/QrScanningClickIntents.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/QrScanningClickIntents.kt @@ -1,5 +1,13 @@ package com.tangem.feature.qrscanning.viewmodel +import com.tangem.feature.qrscanning.presentation.QrScanningStateController +import com.tangem.feature.qrscanning.presentation.transformers.DismissBottomSheetTransformer +import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.launch +import javax.inject.Inject + interface QrScanningClickIntents { fun onBackClick() @@ -7,4 +15,39 @@ interface QrScanningClickIntents { fun onQrScanned(qrCode: String) fun onGalleryClicked() +} + +@ViewModelScoped +internal class QrScanningClickIntentsImplementor @Inject constructor( + private val stateHolder: QrScanningStateController, + private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase, + private val dispatcher: CoroutineDispatcherProvider, +) : BaseQrScanningClickIntents(), QrScanningClickIntents { + + private var isScanned = false + + override fun onBackClick() = router.popBackStack() + + override fun onQrScanned(qrCode: String) { + if (qrCode.isNotBlank()) { + if (!isScanned) { + router.popBackStack() + isScanned = true + } + viewModelScope.launch(dispatcher.main) { + emitQrScannedEventUseCase.invoke(source, qrCode) + } + } + } + + override fun onGalleryClicked() { + galleryLauncher.launch(GALLERY_IMAGE_FILTER) + if (stateHolder.value.bottomSheetConfig != null) { + stateHolder.update(DismissBottomSheetTransformer()) + } + } + + companion object { + private const val GALLERY_IMAGE_FILTER = "image/*" + } } \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/QrScanningViewModel.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/QrScanningViewModel.kt index b0eef6d35c..5402f469b0 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/QrScanningViewModel.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/viewmodel/QrScanningViewModel.kt @@ -1,8 +1,6 @@ package com.tangem.feature.qrscanning.viewmodel -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.activity.result.ActivityResultLauncher import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -11,50 +9,38 @@ import com.tangem.feature.qrscanning.QrScanningRouter.Companion.SOURCE_KEY import com.tangem.feature.qrscanning.SourceType import com.tangem.feature.qrscanning.navigation.QrScanningInnerRouter import com.tangem.feature.qrscanning.presentation.QrScanningState -import com.tangem.feature.qrscanning.presentation.QrScanningStateFactory -import com.tangem.feature.qrscanning.usecase.EmitQrScannedEventUseCase -import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.feature.qrscanning.presentation.QrScanningStateController +import com.tangem.feature.qrscanning.presentation.transformers.ShowCameraDeniedBottomSheetTransformer +import com.tangem.feature.qrscanning.presentation.transformers.InitializeQrScanningStateTransformer import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject -import kotlin.properties.Delegates @HiltViewModel internal class QrScanningViewModel @Inject constructor( - private val emitQrScannedEventUseCase: EmitQrScannedEventUseCase, - private val dispatcher: CoroutineDispatcherProvider, + private val stateHolder: QrScanningStateController, + private val clickIntents: QrScanningClickIntentsImplementor, savedStateHandle: SavedStateHandle, -) : ViewModel(), QrScanningClickIntents { +) : ViewModel() { private val source: SourceType = savedStateHandle[SOURCE_KEY] ?: error("Source is mandatory") private val network: String? = savedStateHandle[NETWORK_KEY] - private val factory = QrScanningStateFactory( - clickIntents = this, - ) + val uiState: StateFlow = stateHolder.uiState - var router: QrScanningInnerRouter by Delegates.notNull() - - var uiState: QrScanningState by mutableStateOf(factory.getInitialState(source, network)) - private set - - private var isScanned = false - - override fun onBackClick() = router.popBackStack() - - override fun onQrScanned(qrCode: String) { - if (qrCode.isNotBlank()) { - if (!isScanned) { - router.popBackStack() - isScanned = true - } - viewModelScope.launch(dispatcher.main) { - emitQrScannedEventUseCase.invoke(source, qrCode) - } - } + fun setRouter(router: QrScanningInnerRouter, galleryLauncher: ActivityResultLauncher) { + clickIntents.initialize( + router = router, + source = source, + galleryLauncher = galleryLauncher, + coroutineScope = viewModelScope, + ) + stateHolder.update(InitializeQrScanningStateTransformer(clickIntents, source, network)) } - override fun onGalleryClicked() { - // [REDACTED_JIRA] + fun onQrScanned(qrCode: String) = clickIntents.onQrScanned(qrCode) + + fun onCameraDeniedState() { + stateHolder.update(ShowCameraDeniedBottomSheetTransformer(clickIntents)) } } \ No newline at end of file diff --git a/features/referral/data/build.gradle.kts b/features/referral/data/build.gradle.kts index 76c384d6d6..5125499148 100644 --- a/features/referral/data/build.gradle.kts +++ b/features/referral/data/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.feature.referral.data" +} + dependencies { /** Project */ diff --git a/features/referral/data/src/main/AndroidManifest.xml b/features/referral/data/src/main/AndroidManifest.xml deleted file mode 100644 index 6f8fe3941b..0000000000 --- a/features/referral/data/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/features/referral/domain/src/main/AndroidManifest.xml b/features/referral/domain/src/main/AndroidManifest.xml deleted file mode 100644 index ace8d8e355..0000000000 --- a/features/referral/domain/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/features/referral/presentation/build.gradle.kts b/features/referral/presentation/build.gradle.kts index 280c994188..0a90fdc329 100644 --- a/features/referral/presentation/build.gradle.kts +++ b/features/referral/presentation/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.feature.referral.presentation" +} + dependencies { /** Core modules */ implementation(project(":core:analytics")) diff --git a/features/referral/presentation/src/main/AndroidManifest.xml b/features/referral/presentation/src/main/AndroidManifest.xml deleted file mode 100644 index 4c5a0a1f54..0000000000 --- a/features/referral/presentation/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ 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 1189a49843..539fd6d734 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 @@ -73,7 +73,7 @@ internal class SendFragment : ComposeFragment() { SystemBarsEffect { setSystemBarsColor(systemBarsColor) } - SendScreen(viewModel.uiState) + SendScreen(viewModel.uiState, viewModel.stateRouter.currentState) } override fun onDestroy() { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/SendAnalyticEvents.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/SendAnalyticEvents.kt index b62f028e0a..2f48bcb034 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/SendAnalyticEvents.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/SendAnalyticEvents.kt @@ -1,9 +1,12 @@ package com.tangem.features.send.impl.presentation.analytics import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE import com.tangem.core.analytics.models.AnalyticsParam.Key.VALIDATION +import com.tangem.core.analytics.models.AnalyticsParam.OnOffState /** * Send screen analytics @@ -13,24 +16,9 @@ internal sealed class SendAnalyticEvents( params: Map = mapOf(), ) : AnalyticsEvent(category = "Token / Send", event = event, params = params) { - /** Send screen opened */ - object SendOpened : SendAnalyticEvents(event = "Send Screen Opened") - - /** Next button clicked */ - data class NextButtonClicked(val source: SendScreenSource) : SendAnalyticEvents( - event = "Button - Next", - params = mapOf(SOURCE to source.name), - ) - - /** Back button clicked */ - data class BackButtonClicked(val source: SendScreenSource) : SendAnalyticEvents( - event = "Button - Back", - params = mapOf(SOURCE to source.name), - ) - // region Address /** Recipient address screen opened */ - object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened") + data object AddressScreenOpened : SendAnalyticEvents(event = "Address Screen Opened") /** Address to send entered */ data class AddressEntered(val source: EnterAddressSource, val isValid: Boolean) : SendAnalyticEvents( @@ -41,21 +29,13 @@ internal sealed class SendAnalyticEvents( ), ) - /** Paste from clipboard button clicked */ - data class PasteButtonClicked(val type: PasteType) : SendAnalyticEvents( - event = "Button - Paste", - params = mapOf( - TYPE to type.name, - ), - ) - /** Qr Code button clicked */ - object QrCodeButtonClicked : SendAnalyticEvents(event = "Button - QR Code") + data object QrCodeButtonClicked : SendAnalyticEvents(event = "Button - QR Code") // endregion // region Amount /** Amount screen opened */ - object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened") + data object AmountScreenOpened : SendAnalyticEvents(event = "Amount Screen Opened") /** Selected currency */ data class SelectedCurrency(val type: SelectedCurrencyType) : SendAnalyticEvents( @@ -63,36 +43,36 @@ internal sealed class SendAnalyticEvents( params = mapOf(TYPE to type.value), ) - /** Currency selector button clicked */ - object SwapCurrencyButtonClicked : SendAnalyticEvents(event = "Button - Swap Currency") + /** Max amount button clicked */ + data object MaxAmountButtonClicked : SendAnalyticEvents(event = "Max Amount Taped") // endregion // region Fee /** Fee screen opened */ - object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened") + data object FeeScreenOpened : SendAnalyticEvents(event = "Fee Screen Opened") /** Selected fee (send after next screen opened) */ - data class SelectedFee(val fee: String) : SendAnalyticEvents( + data class SelectedFee(val feeType: SelectedFeeType) : SendAnalyticEvents( event = "Fee Selected", - params = mapOf("Commission" to fee), + params = mapOf("Fee Type" to feeType.name), ) /** Custom fee selected */ - object CustomFeeButtonClicked : SendAnalyticEvents(event = "Custom Fee Clicked") + data object CustomFeeButtonClicked : SendAnalyticEvents(event = "Custom Fee Clicked") /** Custom fee edited */ - object GasPriceInserter : SendAnalyticEvents(event = "Gas Price Inserted") + data object GasPriceInserter : SendAnalyticEvents(event = "Gas Price Inserted") /** Subtract from amount selector switched (send after next screen opened) */ - object SubtractFromAmount : SendAnalyticEvents(event = "Subtract from Amount") + data class SubtractFromAmount(val status: Boolean) : SendAnalyticEvents( + event = "Subtract from Amount", + params = mapOf("Status" to if (status) OnOffState.On.value else OnOffState.Off.value), + ) // endregion // region Confirmation /** Confirmation screen opened */ - object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened") - - /** Send transaction button clicked */ - object SendButtonClicked : SendAnalyticEvents(event = "Button - Send") + data object ConfirmationScreenOpened : SendAnalyticEvents(event = "Confirm Screen Opened") /** Screen reopened from confirmation screen */ data class ScreenReopened(val source: SendScreenSource) : SendAnalyticEvents( @@ -103,13 +83,31 @@ internal sealed class SendAnalyticEvents( // region Transaction Result /** Transaction send screen opened */ - object TransactionScreenOpened : SendAnalyticEvents(event = "Transaction Sent Screen Opened") + data object TransactionScreenOpened : SendAnalyticEvents(event = "Transaction Sent Screen Opened") /** Share button clicked */ - object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share") + data object ShareButtonClicked : SendAnalyticEvents(event = "Button - Share") /** Expore button clicked */ - object ExploreButtonClicked : SendAnalyticEvents(event = "Button - Explore") + data object ExploreButtonClicked : SendAnalyticEvents(event = "Button - Explore") + + /** If not enough fee notification is present */ + data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SendAnalyticEvents( + event = "Notice - Not Enough Fee", + params = mapOf(TOKEN to token, BLOCKCHAIN to blockchain), + ) + + /** If transaction delays notification is present */ + data class NoticeTransactionDelays(val token: String) : SendAnalyticEvents( + event = "Notice - Transaction Delays Are Possible", + params = mapOf(TOKEN to token), + ) + + /** If error occurs during send transactions */ + data class TransactionError(val token: String) : SendAnalyticEvents( + event = "Error - Transaction Rejected", + params = mapOf(TOKEN to token), + ) // endregion } @@ -125,12 +123,15 @@ internal enum class EnterAddressSource { RecentAddress, } -internal enum class PasteType { - Address, - Memo, -} - internal enum class SelectedCurrencyType(val value: String) { Token("Token"), AppCurrency("App Currency"), +} + +internal enum class SelectedFeeType { + Min, + Max, + Fixed, + Normal, + Custom, } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/utils/SendOnNextScreenAnalyticSender.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/utils/SendOnNextScreenAnalyticSender.kt index e3f5666a1c..6ca1759174 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/utils/SendOnNextScreenAnalyticSender.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/utils/SendOnNextScreenAnalyticSender.kt @@ -1,7 +1,9 @@ package com.tangem.features.send.impl.presentation.analytics.utils +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.features.send.impl.presentation.analytics.SelectedCurrencyType +import com.tangem.features.send.impl.presentation.analytics.SelectedFeeType import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiStateType @@ -21,15 +23,12 @@ internal class SendOnNextScreenAnalyticSender( if (selectedFee == FeeType.Custom && isCustomFeeEdited) { analyticsEventHandler.send(SendAnalyticEvents.GasPriceInserter) } - analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(selectedFee.name)) - } - if (feeState.isSubtract) { - analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount) + sendSelectedFeeAnalytics(feeSelectorState) } } SendUiStateType.Amount -> { val isFiatSelected = state.amountState?.amountTextField?.isFiatValue ?: return - val selectedCurrency = if (isFiatSelected) { + val selectedCurrency = if (!isFiatSelected) { SelectedCurrencyType.Token } else { SelectedCurrencyType.AppCurrency @@ -41,4 +40,17 @@ internal class SendOnNextScreenAnalyticSender( else -> Unit } } + + private fun sendSelectedFeeAnalytics(feeSelectorState: FeeSelectorState.Content) { + val type = when (feeSelectorState.fees) { + is TransactionFee.Single -> SelectedFeeType.Fixed + is TransactionFee.Choosable -> when (feeSelectorState.selectedFee) { + FeeType.Slow -> SelectedFeeType.Min + FeeType.Market -> SelectedFeeType.Normal + FeeType.Fast -> SelectedFeeType.Max + FeeType.Custom -> SelectedFeeType.Custom + } + } + analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(type)) + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/utils/SendRecipientAnalyticsSender.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/utils/SendRecipientAnalyticsSender.kt deleted file mode 100644 index 000f48dea1..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/utils/SendRecipientAnalyticsSender.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.tangem.features.send.impl.presentation.analytics.utils - -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource -import com.tangem.features.send.impl.presentation.analytics.PasteType -import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents - -internal class SendRecipientAnalyticsSender( - private val analyticsEventHandler: AnalyticsEventHandler, -) { - - fun sendAddressAnalytics(type: EnterAddressSource?, isValidAddress: Boolean) { - type?.let { - if (type == EnterAddressSource.PasteButton) { - analyticsEventHandler.send(SendAnalyticEvents.PasteButtonClicked(PasteType.Address)) - } - analyticsEventHandler.send(SendAnalyticEvents.AddressEntered(it, isValidAddress)) - } - } - - fun sendMemoAnalytics(isPasted: Boolean) { - if (isPasted) { - analyticsEventHandler.send(SendAnalyticEvents.PasteButtonClicked(PasteType.Memo)) - } - } -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendRecipientListContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendRecipientListContent.kt index 47761c5956..0837f1f27c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendRecipientListContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/domain/SendRecipientListContent.kt @@ -1,23 +1,14 @@ package com.tangem.features.send.impl.presentation.domain import androidx.annotation.DrawableRes -import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference -import kotlinx.collections.immutable.PersistentList -@Immutable -internal sealed class SendRecipientListContent { - data class Item( - val id: String, - val title: TextReference, - val subtitle: TextReference, - val timestamp: TextReference? = null, - val subtitleEndOffset: Int = 0, - @DrawableRes val subtitleIconRes: Int? = null, - ) : SendRecipientListContent() - - data class Wallets( - val list: PersistentList, - val isWalletsOnly: Boolean, - ) : SendRecipientListContent() -} \ No newline at end of file +data class SendRecipientListContent( + val id: String, + val title: TextReference, + val subtitle: TextReference, + val timestamp: TextReference? = null, + val subtitleEndOffset: Int = 0, + @DrawableRes val subtitleIconRes: Int? = null, + val isVisible: Boolean = true, +) \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt index 672ceb39d5..30becd1f7e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendAlertState.kt @@ -20,7 +20,7 @@ internal sealed class SendAlertState { ) : SendAlertState() { override val message: TextReference = resourceReference(R.string.common_unknown_error) override val confirmButtonText: TextReference = - resourceReference(id = R.string.send_alert_button_request_support) + resourceReference(id = R.string.common_support) } data class TransactionError( @@ -35,7 +35,7 @@ internal sealed class SendAlertState { formatArgs = wrappedList(causeTextReference ?: cause.orEmpty(), code), ) override val confirmButtonText: TextReference = - resourceReference(id = R.string.send_alert_button_request_support) + resourceReference(id = R.string.common_support) } data class DemoMode( @@ -45,11 +45,28 @@ internal sealed class SendAlertState { override val message: TextReference = resourceReference(id = R.string.warning_demo_mode_message) } - object FeeIncreased : SendAlertState() { + data object FeeIncreased : SendAlertState() { override val title: TextReference? = null override val message: TextReference = resourceReference(id = R.string.send_notification_high_fee_title) } + data class FeeTooLow( + override val onConfirmClick: () -> Unit, + ) : SendAlertState() { + override val title: TextReference? = null + override val message: TextReference = resourceReference(id = R.string.send_alert_fee_too_low_text) + override val confirmButtonText: TextReference = resourceReference(R.string.common_continue) + } + + data class FeeCoverage( + override val onConfirmClick: (() -> Unit), + ) : SendAlertState() { + override val title: TextReference? = null + override val message: TextReference = resourceReference(id = R.string.send_alert_fee_coverage_title) + override val confirmButtonText: TextReference = + resourceReference(id = R.string.send_alert_fee_coverage_subract_text) + } + data class ReserveAmount(val amount: String) : SendAlertState() { override val title: TextReference = resourceReference(id = R.string.send_notification_invalid_reserve_amount_title, wrappedList(amount)) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt index 23c9aec225..490e25e8e1 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendEventStateFactory.kt @@ -43,6 +43,20 @@ internal class SendEventStateFactory( ) } + fun getFeeCoverageAlert(onConsume: () -> Unit): SendUiState { + val state = currentStateProvider() + return state.copy( + event = triggeredEvent( + data = SendEvent.ShowAlert( + SendAlertState.FeeCoverage( + onConfirmClick = clickIntents::onSubtractSelect, + ), + ), + onConsume = onConsume, + ), + ) + } + fun getFeeUpdatedAlert(fee: TransactionFee, onConsume: () -> Unit, onFeeNotIncreased: () -> Unit): SendUiState { val state = currentStateProvider() val feeSelector = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return state @@ -74,6 +88,20 @@ internal class SendEventStateFactory( } } + fun getFeeTooLowAlert(onConsume: () -> Unit): SendUiState { + val state = currentStateProvider() + return state.copy( + event = triggeredEvent( + data = SendEvent.ShowAlert( + SendAlertState.FeeTooLow( + onConfirmClick = clickIntents::showSend, + ), + ), + onConsume = onConsume, + ), + ) + } + fun getGenericErrorState(error: Throwable? = null, onConsume: () -> Unit): SendUiState { val state = currentStateProvider() return state.copy( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt index cdbd005127..bf5148d51e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotification.kt @@ -12,12 +12,14 @@ internal sealed class SendNotification(val config: NotificationConfig) { title: TextReference, subtitle: TextReference, buttonState: NotificationConfig.ButtonsState? = null, + onCloseClick: (() -> Unit)? = null, ) : SendNotification( config = NotificationConfig( title = title, subtitle = subtitle, iconResId = R.drawable.ic_alert_24, buttonsState = buttonState, + onCloseClick = onCloseClick, ), ) { @@ -45,12 +47,17 @@ internal sealed class SendNotification(val config: NotificationConfig) { val cryptoCurrency: String, val utxoLimit: String, val amountLimit: String, + val onConfirmClick: () -> Unit, ) : Error( title = resourceReference(R.string.send_notifiaction_transaction_limit_title), subtitle = resourceReference( - R.string.send_notifiaction_transaction_limit_text, + R.string.send_notification_transaction_limit_text, wrappedList(cryptoCurrency, utxoLimit, amountLimit), ), + buttonState = NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.send_notification_reduce_to, wrappedList(amountLimit)), + onClick = onConfirmClick, + ), ) } @@ -58,32 +65,49 @@ internal sealed class SendNotification(val config: NotificationConfig) { title: TextReference, subtitle: TextReference, buttonsState: NotificationConfig.ButtonsState? = null, + onCloseClick: (() -> Unit)? = null, ) : SendNotification( config = NotificationConfig( title = title, subtitle = subtitle, iconResId = R.drawable.img_attention_20, buttonsState = buttonsState, + onCloseClick = onCloseClick, ), ) { data class HighFeeError( val amount: String, val onConfirmClick: () -> Unit, - val onDismissClick: () -> Unit, + val onCloseClick: () -> Unit, ) : Warning( title = resourceReference(R.string.send_notification_high_fee_title), subtitle = resourceReference(R.string.send_notification_high_fee_text, wrappedList(amount)), - buttonsState = NotificationConfig.ButtonsState.PairButtonsConfig( - primaryText = resourceReference(R.string.send_notification_fee_too_high_accept, wrappedList(amount)), - onPrimaryClick = onConfirmClick, - secondaryText = resourceReference(R.string.send_notification_fee_too_high_ignore), - onSecondaryClick = onDismissClick, + buttonsState = NotificationConfig.ButtonsState.PrimaryButtonConfig( + text = resourceReference(R.string.send_notification_reduce_by, wrappedList(amount)), + onClick = onConfirmClick, ), + onCloseClick = onCloseClick, ) data class ExistentialDeposit(val deposit: String) : Warning( title = resourceReference(R.string.send_notification_existential_deposit_title), subtitle = resourceReference(R.string.send_notification_existential_deposit_text, wrappedList(deposit)), ) + + data class NetworkCoverage( + val amountReducedBy: String, + val amountReduced: String, + ) : Warning( + title = resourceReference(id = R.string.send_network_fee_warning_title), + subtitle = resourceReference( + id = R.string.send_network_fee_warning_content, + formatArgs = wrappedList(amountReducedBy, amountReduced), + ), + ) + + data object FeeTooLow : Warning( + title = resourceReference(id = R.string.send_notification_transaction_delay_title), + subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text), + ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotificationFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotificationFactory.kt index 05d56ca70b..20ad57bca1 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotificationFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/SendNotificationFactory.kt @@ -1,11 +1,17 @@ package com.tangem.features.send.impl.presentation.state import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.wallets.models.UserWallet +import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents +import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState +import com.tangem.features.send.impl.presentation.state.fee.FeeType import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import com.tangem.utils.Provider import com.tangem.utils.isNullOrZero @@ -17,40 +23,48 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import java.math.BigDecimal +@Suppress("LongParameterList") internal class SendNotificationFactory( private val cryptoCurrencyStatusProvider: Provider, private val coinCryptoCurrencyStatusProvider: Provider, private val currentStateProvider: Provider, private val userWalletProvider: Provider, private val currencyChecksRepository: CurrencyChecksRepository, + private val stateRouterProvider: Provider, private val clickIntents: SendClickIntents, + private val analyticsEventHandler: AnalyticsEventHandler, ) { - fun create(): Flow> = currentStateProvider().currentState - .filter { it == SendUiStateType.Send } + fun create(): Flow> = stateRouterProvider().currentState + .filter { it.type == SendUiStateType.Send } .map { val state = currentStateProvider() + val sendState = state.sendState val feeState = state.feeState ?: return@map persistentListOf() val feeAmount = feeState.fee?.amount?.value ?: BigDecimal.ZERO - val amountValue = state.amountState?.amountTextField?.value?.toBigDecimalOrNull() ?: BigDecimal.ZERO - val sendAmount = if (feeState.isSubtract) feeState.receivedAmountValue else amountValue + val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO + val sendAmount = if (sendState.isSubtract) amountValue.minus(feeAmount) else amountValue buildList { // errors addExceedBalanceNotification(feeAmount, sendAmount) - addInvalidAmountNotification(feeState.isSubtract, sendAmount) + addInvalidAmountNotification(sendState.isSubtract, sendAmount) addMinimumAmountErrorNotification(feeAmount, sendAmount) addDustWarningNotification(feeAmount, sendAmount) addTransactionLimitErrorNotification(feeAmount, sendAmount) // warnings + addFeeCoverageNotification(sendState.isSubtract, sendAmount) addExistentialWarningNotification(feeAmount, sendAmount) - addHighFeeWarningNotification(amountValue, state.sendState.ignoreAmountReduce) + addHighFeeWarningNotification(sendAmount, sendState.ignoreAmountReduce) + addTooLowNotification(feeState) }.toImmutableList() } - fun dismissHighFeeWarningState(): SendUiState { + fun dismissNotificationState(clazz: Class): SendUiState { val state = currentStateProvider() val sendState = state.sendState - val updatedNotifications = sendState.notifications.filterNot { it is SendNotification.Warning.HighFeeError } + val notificationsToRemove = sendState.notifications.filterIsInstance(clazz) + val updatedNotifications = sendState.notifications.toMutableList() + updatedNotifications.removeAll(notificationsToRemove) return state.copy( sendState = sendState.copy( ignoreAmountReduce = true, @@ -158,6 +172,13 @@ internal class SendNotificationFactory( cryptoAmount = utxoLimit.maxAmount, cryptoCurrency = cryptoCurrency, ), + onConfirmClick = { + val reduceTo = utxoLimit.maxAmount.toPlainString() + clickIntents.onAmountReduceClick( + reduceTo, + SendNotification.Error.TransactionLimitError::class.java, + ) + }, ), ) } @@ -203,9 +224,11 @@ internal class SendNotificationFactory( amount = TEZOS_FEE_THRESHOLD.toPlainString(), onConfirmClick = { val reduceTo = sendAmount.minus(TEZOS_FEE_THRESHOLD).toPlainString() - clickIntents.onAmountReduceClick(reduceTo) + clickIntents.onAmountReduceClick(reduceTo, SendNotification.Warning.HighFeeError::class.java) + }, + onCloseClick = { + clickIntents.onNotificationCancel(SendNotification.Warning.HighFeeError::class.java) }, - onDismissClick = clickIntents::onAmountReduceIgnoreClick, ), ) } @@ -234,6 +257,45 @@ internal class SendNotificationFactory( } } + private fun MutableList.addFeeCoverageNotification( + isSubtract: Boolean, + amountValue: BigDecimal, + ) { + val state = currentStateProvider() + val cryptoCurrency = cryptoCurrencyStatusProvider().currency + val feeAmount = state.feeState?.fee?.amount?.value ?: BigDecimal.ZERO + + val amountReducedValue = amountValue.minus(feeAmount) + val amountReducedByValue = amountValue.minus(amountReducedValue) + val amountReducedBy = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = amountReducedByValue, + cryptoCurrency = cryptoCurrency, + ) + val amountReduced = BigDecimalFormatter.formatCryptoAmount( + cryptoAmount = amountReducedValue, + cryptoCurrency = cryptoCurrency, + ) + if (isSubtract) { + add(SendNotification.Warning.NetworkCoverage(amountReducedBy, amountReduced)) + } + } + + private fun MutableList.addTooLowNotification(feeState: SendStates.FeeState) { + val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return + val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return + val minimumValue = multipleFees.minimum.amount.value ?: return + val customAmount = feeSelectorState.customValues.firstOrNull() ?: return + val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) + if (feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue) { + add(SendNotification.Warning.FeeTooLow) + analyticsEventHandler.send( + SendAnalyticEvents.NoticeTransactionDelays( + cryptoCurrencyStatusProvider().currency.symbol, + ), + ) + } + } + companion object { private const val CARDANO_MINIMUM = "1" private const val DOGECOIN_MINIMUM = "0.01" 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 57a1a698ae..619ec36aab 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 @@ -1,6 +1,5 @@ package com.tangem.features.send.impl.presentation.state -import androidx.paging.PagingData import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter @@ -23,7 +22,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import com.tangem.utils.Provider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.MutableStateFlow import timber.log.Timber @Suppress("LongParameterList") @@ -78,7 +76,6 @@ internal class SendStateFactory( // region UI states fun getInitialState(): SendUiState = SendUiState( clickIntents = clickIntents, - currentState = MutableStateFlow(SendUiStateType.None), event = consumedEvent(), isEditingDisabled = false, isBalanceHidden = false, @@ -109,17 +106,11 @@ internal class SendStateFactory( //endregion //region recipient - fun onLoadedRecipientList( - wallets: List, - txHistory: PagingData, - txHistoryCount: Int, - ) { + fun onLoadedRecipientList(wallets: List, txHistory: List): SendUiState = recipientListStateConverter.convert( wallets = wallets, txHistory = txHistory, - txHistoryCount = txHistoryCount, ) - } fun onRecipientAddressValueChange(value: String, isXAddress: Boolean = false): SendUiState { val state = currentStateProvider() @@ -223,6 +214,13 @@ internal class SendStateFactory( //endregion //region send + fun onSubtractSelect(isSubtract: Boolean): SendUiState { + val state = currentStateProvider() + return state.copy( + sendState = state.sendState.copy(isSubtract = isSubtract), + ) + } + fun getSendingStateUpdate(isSending: Boolean): SendUiState { val state = currentStateProvider() return state.copy(sendState = state.sendState.copy(isSending = isSending)) 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 1828a6b3ed..7572a37ae9 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 @@ -2,7 +2,6 @@ package com.tangem.features.send.impl.presentation.state import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import androidx.paging.PagingData import com.tangem.blockchain.common.transaction.Fee import com.tangem.core.ui.components.currency.tokenicon.TokenIconState import com.tangem.core.ui.event.StateEvent @@ -17,8 +16,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import java.math.BigDecimal /** @@ -32,8 +29,6 @@ internal data class SendUiState( val recipientState: SendStates.RecipientState? = null, val feeState: SendStates.FeeState? = null, val sendState: SendStates.SendState = SendStates.SendState(), - val recipientList: MutableStateFlow> = MutableStateFlow(PagingData.empty()), - val currentState: StateFlow, val isBalanceHidden: Boolean, val event: StateEvent, ) @@ -64,7 +59,8 @@ internal sealed class SendStates { override val isPrimaryButtonEnabled: Boolean, val addressTextField: SendTextField.RecipientAddress, val memoTextField: SendTextField.RecipientMemo?, - val recipients: MutableStateFlow> = MutableStateFlow(PagingData.empty()), + val recent: ImmutableList, + val wallets: ImmutableList, val network: String, val isValidating: Boolean = false, ) : SendStates() @@ -75,12 +71,7 @@ internal sealed class SendStates { override val type: SendUiStateType = SendUiStateType.Fee, override val isPrimaryButtonEnabled: Boolean = false, val feeSelectorState: FeeSelectorState, - val isSubtractAvailable: Boolean, - val isSubtract: Boolean, - val isUserSubtracted: Boolean, val fee: Fee?, - val receivedAmountValue: BigDecimal, - val receivedAmount: String, val rate: BigDecimal?, val appCurrency: AppCurrency, val isFeeApproximate: Boolean, @@ -94,17 +85,24 @@ internal sealed class SendStates { override val isPrimaryButtonEnabled: Boolean = true, val isSending: Boolean = false, val isSuccess: Boolean = false, + val isSubtract: Boolean = false, val transactionDate: Long = 0L, val txUrl: String = "", val ignoreAmountReduce: Boolean = false, + val isFromConfirmation: Boolean = true, val notifications: ImmutableList = persistentListOf(), ) : SendStates() } +data class SendUiCurrentScreen( + val type: SendUiStateType, + val isFromConfirmation: Boolean, +) + enum class SendUiStateType { None, - Amount, Recipient, + Amount, Fee, Send, } \ 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 ea62b4700d..de09a0570e 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 @@ -3,7 +3,6 @@ package com.tangem.features.send.impl.presentation.state import androidx.fragment.app.FragmentManager import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents -import com.tangem.features.send.impl.presentation.analytics.SendScreenSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -14,106 +13,91 @@ internal class StateRouter( private val analyticsEventsHandler: AnalyticsEventHandler, private val isEditingDisabled: Boolean, ) { - private var mutableCurrentState: MutableStateFlow = MutableStateFlow( - if (isEditingDisabled) { - SendUiStateType.None - } else { - SendUiStateType.Recipient - }, - ) + private var mutableCurrentState: MutableStateFlow = MutableStateFlow(getInitState()) - val currentState: StateFlow = mutableCurrentState + val currentState: StateFlow + get() = mutableCurrentState + + fun clear() { + mutableCurrentState.update { getInitState() } + } fun popBackStack() { fragmentManager.get()?.popBackStack() } fun onBackClick(isSuccess: Boolean = false) { + val type = currentState.value.type when { isSuccess -> popBackStack() - isEditingDisabled -> when (currentState.value) { - SendUiStateType.Send -> { - analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee)) - showFee() - } + isEditingDisabled -> when (type) { + SendUiStateType.Send -> showFee() else -> popBackStack() } - else -> when (currentState.value) { - SendUiStateType.Amount -> { - analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Address)) - showRecipient() - } - SendUiStateType.Fee -> { - analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount)) - showAmount() - } - SendUiStateType.Send -> { - analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee)) - showFee() - } - else -> popBackStack() + else -> when (type) { + SendUiStateType.Amount -> continueToSend(::showRecipient) + SendUiStateType.Fee -> continueToSend(::showAmount) + SendUiStateType.Send -> continueToSend(::showFee) + else -> continueToSend(::popBackStack) } } } - fun onNextClick(): SendUiStateType { - val prevState = currentState.value - when (currentState.value) { - SendUiStateType.Recipient -> { - analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Amount)) - showAmount() - } - SendUiStateType.Amount -> { - analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee)) - showFee() - } - SendUiStateType.Fee -> { - analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee)) - showSend() - } - SendUiStateType.Send -> { - onBackClick() - } + fun onNextClick() { + when (currentState.value.type) { + SendUiStateType.Recipient -> continueToSend(::showAmount) + SendUiStateType.Amount -> continueToSend(::showFee) + SendUiStateType.Fee -> showSend() + SendUiStateType.Send -> onBackClick() else -> popBackStack() } - return prevState } fun onPrevClick() { if (isEditingDisabled) { popBackStack() } else { - when (currentState.value) { - SendUiStateType.Amount -> { - analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount)) - showRecipient() - } - SendUiStateType.Fee -> { - analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee)) - showAmount() - } + when (currentState.value.type) { + SendUiStateType.Amount -> showRecipient() + SendUiStateType.Fee -> showAmount() else -> popBackStack() } } } - fun showAmount() { + fun showAmount(isFromConfirmation: Boolean = false) { analyticsEventsHandler.send(SendAnalyticEvents.AmountScreenOpened) - mutableCurrentState.update { SendUiStateType.Amount } + mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Amount, isFromConfirmation) } } - fun showRecipient() { + fun showRecipient(isFromConfirmation: Boolean = false) { analyticsEventsHandler.send(SendAnalyticEvents.AddressScreenOpened) - mutableCurrentState.update { SendUiStateType.Recipient } + mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Recipient, isFromConfirmation) } } - fun showFee() { + fun showFee(isFromConfirmation: Boolean = false) { analyticsEventsHandler.send(SendAnalyticEvents.FeeScreenOpened) - mutableCurrentState.update { SendUiStateType.Fee } + mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Fee, isFromConfirmation) } } - private fun showSend() { + fun showSend() { analyticsEventsHandler.send(SendAnalyticEvents.ConfirmationScreenOpened) - mutableCurrentState.update { SendUiStateType.Send } + mutableCurrentState.update { SendUiCurrentScreen(SendUiStateType.Send, isFromConfirmation = false) } + } + + private fun continueToSend(show: () -> Unit) { + if (currentState.value.isFromConfirmation) showSend() else show() + } + + private fun getInitState() = if (isEditingDisabled) { + SendUiCurrentScreen( + type = SendUiStateType.None, + isFromConfirmation = false, + ) + } else { + SendUiCurrentScreen( + type = SendUiStateType.Recipient, + isFromConfirmation = false, + ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt index df2a63b153..bbadf98185 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/amount/SendAmountStateConverter.kt @@ -45,7 +45,7 @@ internal class SendAmountStateConverter( ), SendAmountSegmentedButtonsConfig( title = stringReference(appCurrency.code), - iconState = iconStateConverter.convert(status), + iconUrl = appCurrency.iconSmallUrl, isFiat = true, ), ), diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt index 16d58d39a6..1a57669334 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt @@ -1,14 +1,29 @@ package com.tangem.features.send.impl.presentation.state.fee -import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.send.impl.presentation.state.SendUiState -import java.math.BigDecimal /** - * Calculate receiving amount when fee is subtracted from sending amount + * Check if sending amount with fee is greater than balance */ -internal fun calculateReceiveAmount(state: SendUiState, feeAmount: Fee): BigDecimal { - val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO - val fee = feeAmount.amount.value ?: return BigDecimal.ZERO - return amountValue.minus(fee) +internal fun checkFeeCoverage(state: SendUiState, cryptoCurrencyStatus: CryptoCurrencyStatus): Boolean { + val balance = cryptoCurrencyStatus.value.amount ?: return false + val fee = state.feeState?.fee?.amount?.value ?: return false + val amount = state.amountState?.amountTextField?.cryptoAmount?.value ?: return false + return balance <= amount + fee +} + +/** + * Check if custom fee is too low + */ +internal fun checkIfFeeTooLow(state: SendUiState): Boolean { + val feeSelectorState = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return false + val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return false + val minimumValue = multipleFees.minimum.amount.value ?: return false + val customAmount = feeSelectorState.customValues.firstOrNull() ?: return false + val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) + + return feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeNotificationFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeNotificationFactory.kt index fd4fa1da94..d3fcad540b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeNotificationFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeNotificationFactory.kt @@ -1,17 +1,21 @@ package com.tangem.features.send.impl.presentation.state.fee +import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.utils.parseToBigDecimal +import com.tangem.domain.common.extensions.fromNetworkId import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.wallets.models.UserWallet import com.tangem.features.send.impl.R -import com.tangem.features.send.impl.presentation.state.SendStates +import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiStateType +import com.tangem.features.send.impl.presentation.state.StateRouter import com.tangem.features.send.impl.presentation.state.fields.SendTextField import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import com.tangem.utils.Provider @@ -22,17 +26,20 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import java.math.BigDecimal +@Suppress("LongParameterList") internal class FeeNotificationFactory( private val coinCryptoCurrencyStatusProvider: Provider, private val cryptoCurrencyStatusProvider: Provider, private val currentStateProvider: Provider, private val userWalletProvider: Provider, + private val stateRouterProvider: Provider, private val clickIntents: SendClickIntents, private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, + private val analyticsEventHandler: AnalyticsEventHandler, ) { - fun create() = currentStateProvider().currentState - .filter { it == SendUiStateType.Fee } + fun create() = stateRouterProvider().currentState + .filter { it.type == SendUiStateType.Fee } .map { val state = currentStateProvider() val feeState = state.feeState ?: return@map persistentListOf() @@ -45,9 +52,7 @@ internal class FeeNotificationFactory( is FeeSelectorState.Content -> { val customFee = feeSelectorState.customValues val selectedFee = feeSelectorState.selectedFee - addTooLowNotification(feeSelectorState.fees, selectedFee, customFee) addTooHighNotification(feeSelectorState.fees, selectedFee, customFee) - addFeeCoverageNotification(feeState, state.amountState) addExceedsBalanceNotification(feeState.fee) } } @@ -60,20 +65,6 @@ internal class FeeNotificationFactory( } } - private fun MutableList.addTooLowNotification( - transactionFee: TransactionFee, - selectedFee: FeeType, - customFee: List, - ) { - val multipleFees = transactionFee as? TransactionFee.Choosable ?: return - val minimumValue = multipleFees.minimum.amount.value ?: return - val customAmount = customFee.firstOrNull() ?: return - val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) - if (selectedFee == FeeType.Custom && minimumValue > customValue) { - add(SendFeeNotification.Warning.TooLow) - } - } - private fun MutableList.addTooHighNotification( transactionFee: TransactionFee, selectedFee: FeeType, @@ -89,20 +80,6 @@ internal class FeeNotificationFactory( } } - private fun MutableList.addFeeCoverageNotification( - feeState: SendStates.FeeState, - amountState: SendStates.AmountState?, - ) { - if (!feeState.isSubtractAvailable) return - - val cryptoAmount = coinCryptoCurrencyStatusProvider().value.amount ?: return - val feeValue = feeState.fee?.amount?.value ?: return - val value = amountState?.amountTextField?.cryptoAmount?.value ?: return - if (cryptoAmount <= value + feeValue && feeState.isSubtract && !feeState.isUserSubtracted) { - add(SendFeeNotification.Warning.NetworkCoverage) - } - } - private suspend fun MutableList.addExceedsBalanceNotification(fee: Fee?) { val feeValue = fee?.amount?.value ?: BigDecimal.ZERO val userWalletId = userWalletProvider().walletId @@ -118,15 +95,17 @@ internal class FeeNotificationFactory( ifRight = { it }, ) ?: return + val mergeFeeNetworkName = cryptoCurrencyStatus.shouldMergeFeeNetworkName() when (warning) { is CryptoCurrencyWarning.BalanceNotEnoughForFee -> { add( SendFeeNotification.Error.ExceedsBalance( - warning.coinCurrency.networkIconResId, + networkIconId = warning.coinCurrency.networkIconResId, networkName = warning.coinCurrency.name, currencyName = cryptoCurrencyStatus.currency.name, feeName = warning.coinCurrency.name, feeSymbol = warning.coinCurrency.symbol, + mergeFeeNetworkName = mergeFeeNetworkName, onClick = { clickIntents.onTokenDetailsClick( userWalletId = userWalletId, @@ -135,6 +114,12 @@ internal class FeeNotificationFactory( }, ), ) + analyticsEventHandler.send( + SendAnalyticEvents.NoticeNotEnoughFee( + token = cryptoCurrencyStatus.currency.symbol, + blockchain = cryptoCurrencyStatus.currency.network.name, + ), + ) } is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> { val currency = warning.feeCurrency @@ -145,6 +130,7 @@ internal class FeeNotificationFactory( feeName = warning.feeCurrencyName, feeSymbol = warning.feeCurrencySymbol, networkName = warning.networkName, + mergeFeeNetworkName = mergeFeeNetworkName, onClick = currency?.let { { clickIntents.onTokenDetailsClick( @@ -155,11 +141,22 @@ internal class FeeNotificationFactory( }, ), ) + analyticsEventHandler.send( + SendAnalyticEvents.NoticeNotEnoughFee( + token = warning.currency.symbol, + blockchain = warning.networkName, + ), + ) } else -> Unit } } + // workaround for networks that users have misunderstanding + private fun CryptoCurrencyStatus.shouldMergeFeeNetworkName(): Boolean { + return Blockchain.fromNetworkId(this.currency.network.backendId) == Blockchain.Arbitrum + } + companion object { private val FEE_MAX_DIFF = BigDecimal(5) private const val HIGH_FEE_DIFF_DECIMALS = 0 diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt index b8f99a5511..4154ae14a3 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeStateFactory.kt @@ -3,7 +3,6 @@ package com.tangem.features.send.impl.presentation.state.fee import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.extensions.isZero -import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus @@ -14,7 +13,6 @@ import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import com.tangem.utils.Provider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import java.math.BigDecimal /** * Factory to produce fee state for [SendUiState] @@ -22,7 +20,6 @@ import java.math.BigDecimal internal class FeeStateFactory( private val clickIntents: SendClickIntents, private val currentStateProvider: Provider, - private val coinCryptoCurrencyStatusProvider: Provider, private val cryptoCurrencyStatusProvider: Provider, private val appCurrencyProvider: Provider, private val isFeeApproximateUseCase: IsFeeApproximateUseCase, @@ -55,56 +52,26 @@ internal class FeeStateFactory( ) } - fun onFeeOnLoadedState(fees: TransactionFee, isSubtractAvailable: Boolean): SendUiState { + fun onFeeOnLoadedState(fees: TransactionFee): SendUiState { val state = currentStateProvider() - val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO val feeState = state.feeState ?: return state val feeSelectorState = (feeState.feeSelectorState as? FeeSelectorState.Content)?.copy( fees = fees, - customValues = customFeeFieldConverter.convert(fees.normal), ) ?: FeeSelectorState.Content( fees = fees, customValues = customFeeFieldConverter.convert(fees.normal), ) val fee = feeConverter.convert(feeSelectorState) - val receivedAmount = calculateReceiveAmount(state, fee) return state.copy( feeState = feeState.copy( - isSubtractAvailable = isSubtractAvailable, feeSelectorState = feeSelectorState, fee = fee, - receivedAmountValue = receivedAmount, - receivedAmount = getFormattedValue(receivedAmount), - isSubtract = isSubtractAvailable && checkAutoSubtract(state, fee, balance), isFeeApproximate = isFeeApproximate(fee), ), ) } - fun onFeeOnLoadedState(fees: TransactionFee): SendUiState { - val state = currentStateProvider() - val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO - val feeState = state.feeState ?: return state - val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state - - val updatedFeeSelector = feeSelectorState.copy( - fees = fees, - customValues = customFeeFieldConverter.convert(fees.normal), - ) - val fee = feeConverter.convert(updatedFeeSelector) - val receivedAmount = calculateReceiveAmount(state, fee) - return state.copy( - feeState = feeState.copy( - feeSelectorState = updatedFeeSelector, - fee = fee, - receivedAmountValue = receivedAmount, - receivedAmount = getFormattedValue(receivedAmount), - isSubtract = checkAutoSubtract(state, fee, balance), - ), - ) - } - fun onFeeOnErrorState(): SendUiState { val state = currentStateProvider() return state.copy( @@ -118,18 +85,13 @@ internal class FeeStateFactory( val state = currentStateProvider() val feeState = state.feeState ?: return state val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state - val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO val updatedFeeSelectorState = feeSelectorState.copy(selectedFee = feeType) val fee = feeConverter.convert(updatedFeeSelectorState) - val receivedAmount = calculateReceiveAmount(state, fee) return state.copy( feeState = feeState.copy( fee = fee, feeSelectorState = updatedFeeSelectorState, - receivedAmountValue = receivedAmount, - receivedAmount = getFormattedValue(receivedAmount), - isSubtract = checkAutoSubtract(state, fee, balance), ), ) } @@ -139,34 +101,12 @@ internal class FeeStateFactory( val feeState = state.feeState ?: return state val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state val updatedFeeSelectorState = customFeeFieldConverter.onValueChange(feeSelectorState, index, value) - val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO val fee = feeConverter.convert(updatedFeeSelectorState) - val receivedAmount = calculateReceiveAmount(state, fee) return state.copy( feeState = feeState.copy( feeSelectorState = updatedFeeSelectorState, fee = fee, - receivedAmountValue = receivedAmount, - receivedAmount = getFormattedValue(receivedAmount), - isSubtract = checkAutoSubtract(state, fee, balance), - ), - ) - } - - fun onSubtractSelect(value: Boolean): SendUiState { - val state = currentStateProvider() - val feeState = state.feeState ?: return state - val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return state - val fee = feeConverter.convert(feeSelectorState) - val receivedAmount = calculateReceiveAmount(state, fee) - return state.copy( - feeState = feeState.copy( - isSubtract = value, - isUserSubtracted = true, - receivedAmountValue = receivedAmount, - receivedAmount = getFormattedValue(receivedAmount), - fee = fee, ), ) } @@ -187,9 +127,6 @@ internal class FeeStateFactory( ): Boolean { val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return false val customValue = feeSelectorState.customValues.firstOrNull() - val balance = coinCryptoCurrencyStatusProvider().value.amount ?: BigDecimal.ZERO - val fee = feeConverter.convert(feeSelectorState) - val feeValue = fee.amount.value ?: BigDecimal.ZERO val isNotCustom = feeSelectorState.selectedFee != FeeType.Custom val isNotEmptyCustom = if (customValue != null) { @@ -198,24 +135,8 @@ internal class FeeStateFactory( false } val noErrors = notifications.none { it is SendFeeNotification.Error } - val isSubtractRequired = when { - !feeState.isSubtractAvailable -> true // current currency is not fee currency - feeValue + feeState.receivedAmountValue >= balance -> feeState.isSubtract - else -> feeValue + feeState.receivedAmountValue <= balance - } - return noErrors && isSubtractRequired && (isNotEmptyCustom || isNotCustom) - } - - private fun checkAutoSubtract(state: SendUiState, fee: Fee, balance: BigDecimal): Boolean { - val feeState = state.feeState ?: return false - val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO - val feeAmount = fee.amount.value ?: BigDecimal.ZERO - return if (feeState.isUserSubtracted) { - feeState.isSubtract - } else { - amountValue + feeAmount >= balance - } + return noErrors && (isNotEmptyCustom || isNotCustom) } private fun isFeeApproximate(fee: Fee): Boolean { @@ -225,13 +146,4 @@ internal class FeeStateFactory( amountType = fee.amount.type, ) } - - private fun getFormattedValue(value: BigDecimal): String { - val cryptoCurrency = cryptoCurrencyStatusProvider().currency - return BigDecimalFormatter.formatCryptoAmount( - cryptoAmount = value, - cryptoCurrency = cryptoCurrency.symbol, - decimals = cryptoCurrency.decimals, - ) - } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeNotification.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeNotification.kt index f895fb4921..b3a2846e5f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeNotification.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeNotification.kt @@ -20,11 +20,6 @@ sealed class SendFeeNotification(val config: NotificationConfig) { buttonsState = buttonsState, ), ) { - object TooLow : Warning( - title = resourceReference(id = R.string.send_notification_transaction_delay_title), - subtitle = resourceReference(id = R.string.send_notification_transaction_delay_text), - ) - data class TooHigh( val value: String, ) : Warning( @@ -32,11 +27,6 @@ sealed class SendFeeNotification(val config: NotificationConfig) { subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)), ) - object NetworkCoverage : Warning( - title = resourceReference(id = R.string.send_network_fee_warning_title), - subtitle = resourceReference(id = R.string.send_network_fee_warning_content), - ) - data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning( title = resourceReference(R.string.send_fee_unreachable_error_title), subtitle = resourceReference(R.string.send_fee_unreachable_error_text), @@ -66,6 +56,7 @@ sealed class SendFeeNotification(val config: NotificationConfig) { val feeName: String, val feeSymbol: String, val networkName: String, + val mergeFeeNetworkName: Boolean = false, val onClick: (() -> Unit)? = null, ) : Error( title = resourceReference( @@ -79,7 +70,16 @@ sealed class SendFeeNotification(val config: NotificationConfig) { iconResId = networkIconId, buttonsState = onClick?.let { NotificationConfig.ButtonsState.SecondaryButtonConfig( - text = resourceReference(R.string.common_buy_currency, wrappedList(feeName)), + text = resourceReference( + R.string.common_buy_currency, + wrappedList( + if (mergeFeeNetworkName) { + "$currencyName ($feeSymbol)" + } else { + feeName + }, + ), + ), onClick = onClick, ) }, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeStateConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeStateConverter.kt index 1dbee6b3fd..2659080a08 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeStateConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/SendFeeStateConverter.kt @@ -6,7 +6,6 @@ import com.tangem.features.send.impl.presentation.state.SendStates import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf -import java.math.BigDecimal internal class SendFeeStateConverter( private val appCurrencyProvider: Provider, @@ -17,12 +16,7 @@ internal class SendFeeStateConverter( val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() return SendStates.FeeState( feeSelectorState = FeeSelectorState.Loading, - isSubtractAvailable = false, - isSubtract = false, - isUserSubtracted = false, fee = null, - receivedAmountValue = BigDecimal.ZERO, - receivedAmount = "", notifications = persistentListOf(), rate = cryptoCurrencyStatus.value.fiatRate, appCurrency = appCurrencyProvider(), diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/custom/EthereumCustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/custom/EthereumCustomFeeConverter.kt index 4be7b3acfb..bf12d6f398 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/custom/EthereumCustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/custom/EthereumCustomFeeConverter.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.extensions.isZero import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -31,9 +32,10 @@ internal class EthereumCustomFeeConverter( ) : Converter> { override fun convert(value: Fee.Ethereum): ImmutableList { + val feeValue = value.amount.value return persistentListOf( SendTextField.CustomFee( - value = value.amount.value?.parseBigDecimal(value.amount.decimals).orEmpty(), + value = feeValue?.parseBigDecimal(value.amount.decimals).orEmpty(), decimals = value.amount.decimals, symbol = value.amount.currencySymbol, onValueChange = { clickIntents.onCustomFeeValueChange(FEE_AMOUNT, it) }, @@ -43,7 +45,7 @@ internal class EthereumCustomFeeConverter( ), title = resourceReference(R.string.send_max_fee), footer = resourceReference(R.string.send_max_fee_footer), - label = getFeeFormatted(value.amount.value), + label = getFeeFormatted(feeValue), keyboardActions = KeyboardActions(), ), SendTextField.CustomFee( @@ -67,7 +69,7 @@ internal class EthereumCustomFeeConverter( footer = resourceReference(R.string.send_gas_limit_footer), onValueChange = { clickIntents.onCustomFeeValueChange(GAS_LIMIT, it) }, keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, + imeAction = if (checkExceedBalance(feeValue)) ImeAction.None else ImeAction.Done, keyboardType = KeyboardType.Number, ), keyboardActions = KeyboardActions(onDone = { clickIntents.onNextClick() }), @@ -133,7 +135,16 @@ internal class EthereumCustomFeeConverter( label = getFeeFormatted(newFeeAmount), ), ) - set(index, this[index].copy(value = value)) + set( + index, + this[index].copy( + value = value, + keyboardOptions = KeyboardOptions( + imeAction = if (!checkExceedBalance(newFeeAmount)) ImeAction.None else ImeAction.Done, + keyboardType = KeyboardType.Number, + ), + ), + ) } } }.toImmutableList() @@ -152,6 +163,13 @@ internal class EthereumCustomFeeConverter( ) } + private fun checkExceedBalance(feeAmount: BigDecimal?): Boolean { + val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() + val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO + + return feeAmount == null || feeAmount.isZero() || feeAmount > currencyCryptoAmount + } + companion object { private const val ETHEREUM_GAS_UNIT = "GWEI" private const val ETHEREUM_GAS_DECIMALS = 18 diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt index b9bfa076bd..cd6254be4f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldChangeConverter.kt @@ -1,9 +1,11 @@ package com.tangem.features.send.impl.presentation.state.fields +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import com.tangem.common.extensions.isZero import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal -import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.utils.Provider @@ -19,7 +21,6 @@ internal class SendAmountFieldChangeConverter( val state = currentStateProvider() val amountState = state.amountState ?: return state val amountTextField = amountState.amountTextField - val feeState = state.feeState ?: return state if (value.isEmpty()) return state.emptyState() val cryptoDecimals = amountTextField.cryptoAmount.decimals @@ -33,21 +34,22 @@ internal class SendAmountFieldChangeConverter( val checkValue = if (amountTextField.isFiatValue) fiatValue else cryptoValue val isExceedBalance = checkValue.checkExceedBalance(amountTextField) - val isMaxAmount = checkValue.checkMaxAmount(amountTextField) + val isZero = if (amountTextField.isFiatValue) decimalFiatValue.isZero() else decimalCryptoValue.isZero() return state.copy( amountState = amountState.copy( - isPrimaryButtonEnabled = !isExceedBalance, + isPrimaryButtonEnabled = !isExceedBalance && !isZero, amountTextField = amountTextField.copy( value = cryptoValue, fiatValue = fiatValue, isError = isExceedBalance, cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), + keyboardOptions = KeyboardOptions( + imeAction = if (!isExceedBalance) ImeAction.Done else ImeAction.None, + keyboardType = KeyboardType.Number, + ), ), ), - feeState = feeState.copy( - isSubtract = isMaxAmount, - ), ) } @@ -92,24 +94,9 @@ internal class SendAmountFieldChangeConverter( val fiatDecimal = parseToBigDecimal(amountTextField.fiatAmount.decimals) val cryptoDecimal = parseToBigDecimal(amountTextField.cryptoAmount.decimals) return if (amountTextField.isFiatValue) { - fiatDecimal > currencyFiatAmount || fiatDecimal.isZero() + fiatDecimal > currencyFiatAmount } else { - cryptoDecimal > currencyCryptoAmount || cryptoDecimal.isZero() - } - } - - private fun String.checkMaxAmount(amountTextField: SendTextField.AmountField): Boolean { - val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() - - // If current currency is Token - if (cryptoCurrencyStatus.currency is CryptoCurrency.Token) return false - - val currencyCryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO - val currencyFiatAmount = cryptoCurrencyStatus.value.fiatAmount ?: BigDecimal.ZERO - return if (amountTextField.isFiatValue) { - parseToBigDecimal(amountTextField.fiatAmount.decimals) == currencyFiatAmount - } else { - parseToBigDecimal(amountTextField.cryptoAmount.decimals) == currencyCryptoAmount + cryptoDecimal > currencyCryptoAmount } } } \ 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 83701146e2..ff622738f7 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import com.tangem.blockchain.extensions.toBigDecimalOrDefault +import com.tangem.common.extensions.isZero import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency @@ -36,12 +37,13 @@ internal class SendAmountFieldConverter( val fiatDecimal = cryptoCurrencyStatus.value.fiatRate?.multiply(cryptoDecimal) ?: BigDecimal.ZERO fiatDecimal.parseBigDecimal(FIAT_DECIMALS) } + val isDoneActionEnabled = !cryptoDecimal.isZero() return SendTextField.AmountField( value = value, fiatValue = fiatValue, onValueChange = clickIntents::onAmountValueChange, keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, + imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None, keyboardType = KeyboardType.Number, ), keyboardActions = KeyboardActions(onDone = { clickIntents.onNextClick() }), diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldMaxAmountConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldMaxAmountConverter.kt index 1a9a4d6def..b29459e757 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldMaxAmountConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fields/SendAmountFieldMaxAmountConverter.kt @@ -1,5 +1,8 @@ package com.tangem.features.send.impl.presentation.state.fields +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.CryptoCurrencyStatus import com.tangem.features.send.impl.presentation.state.SendUiState @@ -17,7 +20,6 @@ internal class SendAmountFieldMaxAmountConverter( val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val amountState = state.amountState ?: return state val amountTextField = amountState.amountTextField - val feeState = state.feeState ?: return state val cryptoDecimals = amountTextField.cryptoAmount.decimals val fiatDecimals = amountTextField.fiatAmount.decimals @@ -26,6 +28,7 @@ internal class SendAmountFieldMaxAmountConverter( if (decimalCryptoValue.isNullOrZero()) return state + val isDoneActionEnabled = !decimalCryptoValue.isNullOrZero() val cryptoValue = decimalCryptoValue?.parseBigDecimal(cryptoDecimals).orEmpty() val fiatValue = decimalFiatValue?.parseBigDecimal(fiatDecimals).orEmpty() return state.copy( @@ -37,11 +40,12 @@ internal class SendAmountFieldMaxAmountConverter( isError = false, cryptoAmount = amountTextField.cryptoAmount.copy(value = decimalCryptoValue), fiatAmount = amountTextField.fiatAmount.copy(value = decimalFiatValue), + keyboardOptions = KeyboardOptions( + imeAction = if (isDoneActionEnabled) ImeAction.Done else ImeAction.None, + keyboardType = KeyboardType.Number, + ), ), ), - feeState = feeState.copy( - isSubtract = true, - ), ) } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientListConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientListConverter.kt index 0734f7a243..046c7a153d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientListConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientListConverter.kt @@ -1,6 +1,5 @@ package com.tangem.features.send.impl.presentation.state.recipient -import androidx.paging.* import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList @@ -17,77 +16,66 @@ import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.utils.Provider import com.tangem.utils.toFormattedCurrencyString import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.flow.update internal class SendRecipientListConverter( private val currentStateProvider: Provider, private val cryptoCurrencyStatusProvider: Provider, ) { - fun convert(wallets: List, txHistory: PagingData, txHistoryCount: Int) { - val filteredWallets = wallets.filterNotNull() - .groupBy { item -> item.name } - .values.flatten() - .mapIndexed { index, item -> - item.copy( - name = "${item.name} ${index.inc()}", - ) - } - - val walletsItem = getWalletItems(filteredWallets, txHistoryCount) - + fun convert(wallets: List, txHistory: List): SendUiState { val cryptoCurrency = cryptoCurrencyStatusProvider().currency - currentStateProvider().recipientList.update { - if (txHistoryCount == 0) { - PagingData.from(listOf(walletsItem)) - } else { - txHistory.filter { item -> - val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer - val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User - val isSingleAddress = if (item.isOutgoing) { - item.destinationType is TxHistoryItem.DestinationType.Single - } else { - item.sourceType is TxHistoryItem.SourceType.Single - } - isTransfer && isSingleAddress && isNotContract - }.map { tx -> - SendRecipientListContent.Item( - id = tx.txHash, - title = tx.extractAddress(), - subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()), - timestamp = tx.extractTimestamp(), - subtitleEndOffset = cryptoCurrency.symbol.length, - subtitleIconRes = tx.extractIconRes(), - ) - }.insertWallets(walletsItem) - } - } - } + val state = currentStateProvider() + val recipientState = state.recipientState ?: return state - private fun PagingData.insertWallets( - wallets: SendRecipientListContent.Wallets, - ): PagingData { - return insertSeparators(terminalSeparatorType = TerminalSeparatorType.SOURCE_COMPLETE) { before, after -> - return@insertSeparators when { - before == null && after is SendRecipientListContent.Item -> wallets - else -> null - } - } - } - - private fun getWalletItems(wallets: List, txHistoryCount: Int): SendRecipientListContent.Wallets { - return SendRecipientListContent.Wallets( - wallets.map { - SendRecipientListContent.Item( - id = it.address, - title = TextReference.Str(it.address), - subtitle = TextReference.Str(it.name), - ) - }.toPersistentList(), - isWalletsOnly = txHistoryCount == 0, + return state.copy( + recipientState = recipientState.copy( + wallets = wallets.filterWallets(), + recent = txHistory.filterRecipients(cryptoCurrency), + ), ) } + private fun List.filterWallets() = this.filterNotNull() + .groupBy { item -> item.name } + .values.map { + it.mapIndexed { index, item -> + val name = if (it.size > 1) { + "${item.name} ${index.inc()}" + } else { + item.name + } + SendRecipientListContent( + id = item.address, + title = TextReference.Str(item.address), + subtitle = TextReference.Str(name), + ) + } + } + .flatten() + .toPersistentList() + + private fun List.filterRecipients(cryptoCurrency: CryptoCurrency) = this.filter { item -> + val isTransfer = item.type == TxHistoryItem.TransactionType.Transfer + val isNotContract = item.interactionAddressType is TxHistoryItem.InteractionAddressType.User + val isSingleAddress = if (item.isOutgoing) { + item.destinationType is TxHistoryItem.DestinationType.Single + } else { + item.sourceType is TxHistoryItem.SourceType.Single + } + isTransfer && isSingleAddress && isNotContract + } + .take(RECENT_LIST_SIZE) + .map { tx -> + SendRecipientListContent( + id = tx.txHash, + title = tx.extractAddress(), + subtitle = stringReference(tx.getAmount(cryptoCurrency).trim()), + timestamp = tx.extractTimestamp(), + subtitleEndOffset = cryptoCurrency.symbol.length, + subtitleIconRes = tx.extractIconRes(), + ) + }.toPersistentList() + private fun TxHistoryItem.extractAddress(): TextReference = if (isOutgoing) { when (val destination = destinationType) { is TxHistoryItem.DestinationType.Multiple -> TextReference.Res( @@ -122,4 +110,8 @@ internal class SendRecipientListConverter( val time = timestampInMillis.toTimeFormat() return TextReference.Res(R.string.send_date_format, wrappedList(date, time)) } + + companion object { + private const val RECENT_LIST_SIZE = 10 + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientStateConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientStateConverter.kt index a558d4ded6..ae375c97e2 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientStateConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/recipient/SendRecipientStateConverter.kt @@ -5,6 +5,7 @@ import com.tangem.features.send.impl.presentation.state.SendStates import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import com.tangem.utils.Provider import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.persistentListOf internal class SendRecipientStateConverter( private val clickIntents: SendClickIntents, @@ -25,6 +26,8 @@ internal class SendRecipientStateConverter( memoTextField = memoFieldConverter.convertOrNull(), network = cryptoCurrencyStatusProvider().currency.network.name, isPrimaryButtonEnabled = false, + wallets = persistentListOf(), + recent = persistentListOf(), ) } } \ No newline at end of file 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 b0462ca18f..5c0dfc787b 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 @@ -14,8 +14,6 @@ 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,25 +22,29 @@ 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.* import com.tangem.core.ui.extensions.rememberHapticFeedback import com.tangem.core.ui.extensions.shareText import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiStateType @Composable -internal fun SendNavigationButtons(uiState: SendUiState) { +internal fun SendNavigationButtons(uiState: SendUiState, currentState: SendUiCurrentScreen) { Row( modifier = Modifier .fillMaxWidth() .padding(bottom = TangemTheme.dimens.spacing12), ) { - SendSecondaryNavigationButton(uiState) + SendSecondaryNavigationButton( + uiState = uiState, + currentState = currentState, + ) SendPrimaryNavigationButton( uiState = uiState, + currentState = currentState, modifier = Modifier .weight(1f) .padding(horizontal = TangemTheme.dimens.spacing16), @@ -51,12 +53,12 @@ internal fun SendNavigationButtons(uiState: SendUiState) { } @Composable -private fun SendSecondaryNavigationButton(uiState: SendUiState) { - val currentState = uiState.currentState.collectAsState() +private fun SendSecondaryNavigationButton(uiState: SendUiState, currentState: SendUiCurrentScreen) { val isEditingDisabled = uiState.isEditingDisabled - val isCorrectScreen = currentState.value == SendUiStateType.Amount || currentState.value == SendUiStateType.Fee + val isFromConfirmation = currentState.isFromConfirmation + val isCorrectScreen = currentState.type == SendUiStateType.Amount || currentState.type == SendUiStateType.Fee AnimatedVisibility( - visible = !isEditingDisabled && isCorrectScreen, + visible = !isEditingDisabled && isCorrectScreen && !isFromConfirmation, enter = expandHorizontally(expandFrom = Alignment.End), exit = shrinkHorizontally(shrinkTowards = Alignment.End), ) { @@ -77,8 +79,11 @@ private fun SendSecondaryNavigationButton(uiState: SendUiState) { } @Composable -private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier = Modifier) { - val currentState = uiState.currentState.collectAsStateWithLifecycle() +private fun SendPrimaryNavigationButton( + uiState: SendUiState, + currentState: SendUiCurrentScreen, + modifier: Modifier = Modifier, +) { val isSuccess = uiState.sendState.isSuccess val isSending = uiState.sendState.isSending val txUrl = uiState.sendState.txUrl @@ -100,7 +105,7 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier modifier = modifier, ) { textId -> when { - currentState.value == SendUiStateType.Send && !isSuccess -> { + currentState.type == SendUiStateType.Send && !isSuccess -> { val hapticFeedback = rememberHapticFeedback(state = currentState, onAction = buttonClick) PrimaryButtonIconEnd( text = stringResource(textId), @@ -110,11 +115,11 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier showProgress = isSending, ) } - currentState.value == SendUiStateType.Send && isSuccess -> { + currentState.type == SendUiStateType.Send && isSuccess -> { PrimaryButtonsDone( textRes = textId, txUrl = txUrl, - onExploreClick = { uiState.clickIntents.onExploreClick(txUrl) }, + onExploreClick = uiState.clickIntents::onExploreClick, onShareClick = uiState.clickIntents::onShareClick, onDoneClick = buttonClick, modifier = Modifier, @@ -177,15 +182,19 @@ private fun PrimaryButtonsDone( private fun getButtonData( uiState: SendUiState, - currentState: State, + currentState: SendUiCurrentScreen, isSuccess: Boolean, ): Pair Unit> { - return when (currentState.value) { + return when (currentState.type) { SendUiStateType.None, SendUiStateType.Amount, SendUiStateType.Recipient, SendUiStateType.Fee, - -> R.string.common_next to uiState.clickIntents::onNextClick + -> if (currentState.isFromConfirmation) { + R.string.common_continue to uiState.clickIntents::onNextClick + } else { + R.string.common_next to uiState.clickIntents::onNextClick + } SendUiStateType.Send -> if (isSuccess) { R.string.common_close } else { @@ -194,8 +203,8 @@ private fun getButtonData( } } -private fun isButtonEnabled(currentState: State, uiState: SendUiState): Boolean { - return when (currentState.value) { +private fun isButtonEnabled(currentState: SendUiCurrentScreen, uiState: SendUiState): Boolean { + return when (currentState.type) { SendUiStateType.Amount -> uiState.amountState?.isPrimaryButtonEnabled ?: false SendUiStateType.Recipient -> uiState.recipientState?.isPrimaryButtonEnabled ?: false SendUiStateType.Fee -> uiState.feeState?.isPrimaryButtonEnabled ?: false 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 88c36f0d4c..7cd9b84daf 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 @@ -1,35 +1,34 @@ package com.tangem.features.send.impl.presentation.ui import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.* +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.paging.compose.collectAsLazyPagingItems import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.impl.R +import com.tangem.features.send.impl.presentation.state.SendUiCurrentScreen import com.tangem.features.send.impl.presentation.state.SendUiState import com.tangem.features.send.impl.presentation.state.SendUiStateType import com.tangem.features.send.impl.presentation.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 +import kotlinx.coroutines.flow.StateFlow @Composable -internal fun SendScreen(uiState: SendUiState) { - val currentState = uiState.currentState.collectAsStateWithLifecycle() - val isSuccess = uiState.sendState.isSuccess +internal fun SendScreen(uiState: SendUiState, currentStateFlow: StateFlow) { + val currentState = currentStateFlow.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } BackHandler { uiState.clickIntents.onBackClick() } Column( @@ -40,14 +39,14 @@ internal fun SendScreen(uiState: SendUiState) { .background(color = TangemTheme.colors.background.tertiary), horizontalAlignment = Alignment.CenterHorizontally, ) { - val titleRes = when (currentState.value) { + val titleRes = when (currentState.value.type) { SendUiStateType.Amount -> R.string.send_amount_label SendUiStateType.Recipient -> R.string.send_recipient_label SendUiStateType.Fee -> R.string.common_fee_selector_title - SendUiStateType.Send -> if (!isSuccess) R.string.send_confirm_label else null + SendUiStateType.Send -> if (!uiState.sendState.isSuccess) R.string.send_confirm_label else null else -> null } - val iconRes = if (currentState.value == SendUiStateType.Recipient) { + val iconRes = if (currentState.value.type == SendUiStateType.Recipient) { R.drawable.ic_qrcode_scan_24 } else { null @@ -63,11 +62,11 @@ internal fun SendScreen(uiState: SendUiState) { ) SendScreenContent( uiState = uiState, - currentState = currentState, + currentState = currentState.value, modifier = Modifier .weight(1f), ) - SendNavigationButtons(uiState) + SendNavigationButtons(uiState, currentState.value) } SendEventEffect( @@ -77,18 +76,26 @@ internal fun SendScreen(uiState: SendUiState) { } @Composable -private fun SendScreenContent( - uiState: SendUiState, - currentState: State, - modifier: Modifier = Modifier, -) { - val recipientList = uiState.recipientList.collectAsLazyPagingItems() +private fun SendScreenContent(uiState: SendUiState, currentState: SendUiCurrentScreen, modifier: Modifier = Modifier) { + var lastState by remember { mutableIntStateOf(currentState.type.ordinal) } + val direction = remember(currentState.type.ordinal) { + if (lastState < currentState.type.ordinal) { + AnimatedContentTransitionScope.SlideDirection.Start + } else { + AnimatedContentTransitionScope.SlideDirection.End + } + } AnimatedContent( - targetState = currentState.value, + targetState = currentState, label = "Send Scree Navigation", modifier = modifier, + transitionSpec = { + lastState = currentState.type.ordinal + slideIntoContainer(towards = direction, animationSpec = tween()) + .togetherWith(slideOutOfContainer(towards = direction, animationSpec = tween())) + }, ) { state -> - when (state) { + when (state.type) { SendUiStateType.Amount -> SendAmountContent( amountState = uiState.amountState, isBalanceHiding = uiState.isBalanceHidden, @@ -97,7 +104,6 @@ private fun SendScreenContent( SendUiStateType.Recipient -> SendRecipientContent( uiState = uiState.recipientState, clickIntents = uiState.clickIntents, - recipientList = recipientList, ) SendUiStateType.Fee -> SendSpeedAndFeeContent( state = uiState.feeState, 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 207b6ee43e..92d9b44d51 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,5 +1,6 @@ package com.tangem.features.send.impl.presentation.ui.fee +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,7 +21,10 @@ internal fun SendCustomFeeEthereum( selectedFee: FeeType, modifier: Modifier = Modifier, ) { - if (selectedFee == FeeType.Custom && customValues.isNotEmpty()) { + AnimatedVisibility( + visible = selectedFee == FeeType.Custom && customValues.isNotEmpty(), + label = "Custom Fee Selected Animation", + ) { Column( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), modifier = modifier, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedAndFeeContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedAndFeeContent.kt index 95a469975a..1695763049 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedAndFeeContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedAndFeeContent.kt @@ -15,7 +15,6 @@ import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.impl.presentation.state.SendStates import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState -import com.tangem.features.send.impl.presentation.state.fee.FeeType import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import kotlinx.collections.immutable.ImmutableList @@ -41,8 +40,6 @@ internal fun SendSpeedAndFeeContent(state: SendStates.FeeState?, clickIntents: S topNotifications(notifications) customFee(feeSendState) middleNotifications(notifications) - subtractButton(state, clickIntents) - bottomNotifications(notifications) } } @@ -77,21 +74,7 @@ private fun LazyListScope.middleNotifications( modifier: Modifier = Modifier, ) { notifications( - configs = configs.filter { - it is SendFeeNotification.Warning.TooLow || - it is SendFeeNotification.Warning.TooHigh - }.toImmutableList(), - modifier = modifier, - ) -} - -private fun LazyListScope.bottomNotifications( - configs: ImmutableList, - modifier: Modifier = Modifier, -) { - notifications( - configs = configs.filterIsInstance().toImmutableList(), - isLast = true, + configs = configs.filterIsInstance().toImmutableList(), modifier = modifier, ) } @@ -159,36 +142,4 @@ internal fun LazyListScope.customFee(feeSendState: FeeSelectorState, modifier: M } } } -} - -@OptIn(ExperimentalFoundationApi::class) -internal fun LazyListScope.subtractButton( - state: SendStates.FeeState, - clickIntents: SendClickIntents, - modifier: Modifier = Modifier, -) { - val receivedAmount = state.receivedAmount - val isSubtract = state.isSubtract - val isSubtractAvailable = state.isSubtractAvailable - val feeSendState = state.feeSelectorState - if (isSubtractAvailable) { - item { - val feeStateContent = feeSendState as? FeeSelectorState.Content - val isCustomAvailable = feeStateContent?.customValues.isNullOrEmpty().not() - val isCustomSelected = feeStateContent?.selectedFee == FeeType.Custom - val topPadding = if (isCustomSelected && isCustomAvailable) { - TangemTheme.dimens.spacing12 - } else { - TangemTheme.dimens.spacing20 - } - SendSpeedSubtract( - receivingAmount = receivedAmount, - isSubtract = isSubtract, - onSelectClick = clickIntents::onSubtractSelect, - modifier = modifier - .padding(top = topPadding) - .animateItemPlacement(), - ) - } - } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelector.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelector.kt index 0bac355bb7..f5b10f50a8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelector.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSelector.kt @@ -10,18 +10,23 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.Icon import androidx.compose.material3.Text 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.transaction.Fee @@ -43,7 +48,6 @@ import com.tangem.features.send.impl.presentation.state.SendStates import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState import com.tangem.features.send.impl.presentation.state.fee.FeeType import com.tangem.features.send.impl.presentation.state.fee.SendFeeNotification -import com.tangem.features.send.impl.presentation.ui.common.FooterContainer import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents import java.math.BigDecimal @@ -60,10 +64,7 @@ internal fun SendSpeedSelector( clickIntents: SendClickIntents, modifier: Modifier = Modifier, ) { - FooterContainer( - footer = stringResource(R.string.common_fee_selector_footer), - modifier = modifier, - ) { + Column(modifier = modifier) { Column( modifier = Modifier .fillMaxWidth() @@ -116,10 +117,7 @@ internal fun SendSpeedSelector( visible = fees.normal is Fee.Ethereum, label = "Custom fee appearance animation", ) { - val showWarning = state.notifications.any { - it is SendFeeNotification.Warning.TooHigh || - it is SendFeeNotification.Warning.TooLow - } + val showWarning = state.notifications.any { it is SendFeeNotification.Warning.TooHigh } SendSpeedSelectorItem( titleRes = R.string.common_fee_selector_option_custom, iconRes = R.drawable.ic_edit_24, @@ -147,9 +145,43 @@ internal fun SendSpeedSelector( } } } + FooterText(clickIntents::onReadMoreClick) } } +@Composable +private fun FooterText(onReadMoreClick: () -> Unit) { + val linkText = stringResource(R.string.common_read_more) + val fullString = stringResource(R.string.common_fee_selector_footer, linkText) + val linkTextPosition = fullString.length - linkText.length + val defaultStyle = TangemTheme.colors.text.tertiary + val linkStyle = TangemTheme.colors.text.accent + val annotatedString = remember(defaultStyle, linkStyle) { + buildAnnotatedString { + withStyle(SpanStyle(defaultStyle)) { + append(fullString.substring(0, linkTextPosition)) + } + withStyle(SpanStyle(linkStyle)) { + append(fullString.substring(linkTextPosition, fullString.length)) + } + } + } + + val click = { i: Int -> + val readMoreStyle = requireNotNull(annotatedString.spanStyles.getOrNull(1)) + if (i in readMoreStyle.start..readMoreStyle.end) { + onReadMoreClick() + } + } + + ClickableText( + text = annotatedString, + style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start), + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + onClick = click, + ) +} + // todo remove after refactoring [REDACTED_JIRA] private fun getCryptoReference(amount: Amount, isFeeApproximate: Boolean) = combinedReference( if (isFeeApproximate) stringReference("$CAN_BE_LOWER_SIGN ") else TextReference.EMPTY, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSubtract.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSubtract.kt deleted file mode 100644 index 5fa7355bce..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/fee/SendSpeedSubtract.kt +++ /dev/null @@ -1,61 +0,0 @@ -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.Row -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.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.TangemSwitch -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 SendSpeedSubtract( - receivingAmount: String, - isSubtract: Boolean, - onSelectClick: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - val footerText = if (isSubtract) { - stringResource(R.string.send_amount_substract_footer, receivingAmount) - } else { - null - } - - FooterContainer( - footer = footerText, - modifier = modifier, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.action) - .padding( - vertical = TangemTheme.dimens.spacing16, - horizontal = TangemTheme.dimens.spacing20, - ), - ) { - Text( - text = stringResource(R.string.send_amount_substract), - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing12), - ) - TangemSwitch( - checked = isSubtract, - onCheckedChange = onSelectClick, - ) - } - } -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt index d16846ae48..c33885f290 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/ui/recipient/ListItemWithIcon.kt @@ -3,9 +3,7 @@ package com.tangem.features.send.impl.presentation.ui.recipient import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -18,9 +16,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension -import androidx.constraintlayout.compose.Visibility import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.icons.identicon.IdentIcon @@ -39,7 +34,6 @@ import com.tangem.features.send.impl.R * @param subtitleEndOffset offset for subtitle ellipsis * @param subtitleIconRes icon */ -@Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod") @Composable fun ListItemWithIcon( title: String, @@ -51,80 +45,60 @@ fun ListItemWithIcon( @DrawableRes subtitleIconRes: Int? = null, ) { val hapticFeedback = rememberHapticFeedback(state = title, onAction = onClick) - ConstraintLayout( + Row( modifier = modifier .fillMaxWidth() .clickable { hapticFeedback() } .padding(horizontal = TangemTheme.dimens.spacing12), ) { - val (iconRef, titleRef, subtitleRef, subtitleIconRef) = createRefs() - - val spacing2 = TangemTheme.dimens.spacing2 - val spacing8 = TangemTheme.dimens.spacing8 - val spacing10 = TangemTheme.dimens.spacing10 - val spacing12 = TangemTheme.dimens.spacing12 IdentIcon( address = title, modifier = Modifier + .padding(vertical = TangemTheme.dimens.spacing8) .size(TangemTheme.dimens.size40) - .clip(RoundedCornerShape(TangemTheme.dimens.radius20)) - .constrainAs(iconRef) { - start.linkTo(parent.start) - top.linkTo(parent.top, margin = spacing8) - bottom.linkTo(parent.bottom, margin = spacing8) - }, + .clip(RoundedCornerShape(TangemTheme.dimens.radius20)), ) - EllipsisText( - text = title, - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.Justify, - ellipsis = TextEllipsis.Middle, + Column( modifier = Modifier - .constrainAs(titleRef) { - start.linkTo(iconRef.end, margin = spacing12) - end.linkTo(parent.end) - top.linkTo(parent.top, margin = spacing10) - width = Dimension.fillToConstraints - }, - ) - Icon( - painter = painterResource(id = subtitleIconRes ?: R.drawable.ic_arrow_down_24), - contentDescription = null, - tint = TangemTheme.colors.icon.informative, - modifier = Modifier - .size(TangemTheme.dimens.size16) - .background(TangemTheme.colors.icon.informative.copy(alpha = 0.1f), CircleShape) - .constrainAs(subtitleIconRef) { - start.linkTo(iconRef.end, margin = spacing12) - top.linkTo(titleRef.bottom) - bottom.linkTo(parent.bottom, margin = spacing10) - visibility = if (subtitleIconRes == null) Visibility.Gone else Visibility.Visible - }, - ) - - val (text, offset) = remember(subtitle, info) { - if (info != null) { - val suffix = ", $info" - subtitle + suffix to suffix.length + subtitleEndOffset - } else { - subtitle to 0 + .padding(vertical = TangemTheme.dimens.spacing10) + .padding(start = TangemTheme.dimens.spacing12), + ) { + EllipsisText( + text = title, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Justify, + ellipsis = TextEllipsis.Middle, + modifier = Modifier, + ) + Row { + if (subtitleIconRes != null) { + Icon( + painter = painterResource(id = subtitleIconRes), + contentDescription = null, + tint = TangemTheme.colors.icon.informative, + modifier = Modifier + .size(TangemTheme.dimens.size16) + .background(TangemTheme.colors.background.tertiary, CircleShape), + ) + } + val (text, offset) = remember(subtitle, info) { + if (info != null) { + val suffix = ", $info" + subtitle + suffix to suffix.length + subtitleEndOffset + } else { + subtitle to 0 + } + } + EllipsisText( + text = text, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset), + modifier = Modifier.padding(start = TangemTheme.dimens.spacing2), + ) } } - EllipsisText( - text = text, - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ellipsis = TextEllipsis.OffsetEnd(offsetEnd = offset), - modifier = Modifier - .constrainAs(subtitleRef) { - start.linkTo(subtitleIconRef.end, margin = spacing2, goneMargin = spacing12) - end.linkTo(parent.end) - top.linkTo(titleRef.bottom) - bottom.linkTo(parent.bottom, margin = spacing10) - width = Dimension.fillToConstraints - }, - ) } } 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 c80581a8f8..134cea2336 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 @@ -1,8 +1,9 @@ package com.tangem.features.send.impl.presentation.ui.recipient +import androidx.annotation.StringRes +import androidx.compose.animation.* import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -17,9 +18,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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 @@ -29,18 +27,17 @@ import com.tangem.features.send.impl.presentation.domain.SendRecipientListConten 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 +import kotlinx.collections.immutable.ImmutableList private const val ADDRESS_FIELD_KEY = "ADDRESS_FIELD_KEY" private const val MEMO_FIELD_KEY = "MEMO_FIELD_KEY" -private const val MY_WALLETS_HEADER_KEY = "MY_WALLETS_HEADER_KEY" @Composable -internal fun SendRecipientContent( - uiState: SendStates.RecipientState?, - clickIntents: SendClickIntents, - recipientList: LazyPagingItems, -) { +internal fun SendRecipientContent(uiState: SendStates.RecipientState?, clickIntents: SendClickIntents) { if (uiState == null) return + val recipients = uiState.recent + val wallets = uiState.wallets + val memoField = uiState.memoTextField val address = uiState.addressTextField val isValidating by remember(uiState.isValidating) { derivedStateOf { uiState.isValidating } } val isError by remember(address.isError) { derivedStateOf { address.isError } } @@ -72,7 +69,7 @@ internal fun SendRecipientContent( ) } } - uiState.memoTextField?.let { memoField -> + if (memoField != null) { item(key = MEMO_FIELD_KEY) { val placeholder = if (memoField.isEnabled) memoField.placeholder else memoField.disabledText TextFieldWithPaste( @@ -81,7 +78,7 @@ internal fun SendRecipientContent( placeholder = placeholder, footer = stringResource(R.string.send_recipient_memo_footer), onValueChange = memoField.onValueChange, - onPasteClick = { clickIntents.onRecipientMemoValueChange(it, isPasted = true) }, + onPasteClick = clickIntents::onRecipientMemoValueChange, modifier = Modifier.padding(top = TangemTheme.dimens.spacing20), isError = memoField.isError, error = memoField.error, @@ -89,144 +86,115 @@ internal fun SendRecipientContent( ) } } - recipientListItem( - recipientList = recipientList, - clickIntents = clickIntents, + listHeaderItem( + titleRes = R.string.send_recipient_wallets_title, + isVisible = wallets.isNotEmpty() && wallets.first().isVisible, + isFirst = true, ) + listItem(wallets, clickIntents, isLast = recipients.isEmpty()) + listHeaderItem( + titleRes = R.string.send_recent_transactions, + isVisible = recipients.isNotEmpty() && recipients.first().isVisible, + isFirst = wallets.isEmpty(), + ) + listItem(recipients, clickIntents, isLast = true) } } -@Suppress("LongMethod") @OptIn(ExperimentalFoundationApi::class) -private fun LazyListScope.recipientListItem( - recipientList: LazyPagingItems, +private fun LazyListScope.listHeaderItem(@StringRes titleRes: Int, isVisible: Boolean, isFirst: Boolean) { + item( + key = titleRes, + ) { + AnimatedVisibility( + visible = isVisible, + label = "Header Appearance Animation", + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + modifier = Modifier + .animateItemPlacement() + .animateContentSize(), + ) { + val (topPadding, paddingFromTop) = if (isFirst) { + TangemTheme.dimens.spacing20 to TangemTheme.dimens.spacing12 + } else { + TangemTheme.dimens.spacing0 to TangemTheme.dimens.spacing8 + } + val topRadius = if (isFirst) { + TangemTheme.dimens.radius12 + } else { + TangemTheme.dimens.radius0 + } + Text( + text = stringResource(titleRes), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier + .fillMaxWidth() + .padding(top = topPadding) + .clip( + RoundedCornerShape( + topEnd = topRadius, + topStart = topRadius, + ), + ) + .background(TangemTheme.colors.background.action) + .padding( + top = paddingFromTop, + bottom = TangemTheme.dimens.spacing8, + start = TangemTheme.dimens.spacing12, + end = TangemTheme.dimens.spacing12, + ), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.listItem( + list: ImmutableList, clickIntents: SendClickIntents, + isLast: Boolean, ) { items( - count = recipientList.itemCount, - key = recipientList.itemKey { - when (it) { - is SendRecipientListContent.Wallets -> MY_WALLETS_HEADER_KEY - is SendRecipientListContent.Item -> it.id - } - }, - contentType = recipientList.itemContentType { it::class.java }, + count = list.size, + key = { list[it].id }, + contentType = { list[it]::class.java }, ) { index -> - recipientList[index]?.let { item -> - when (item) { - is SendRecipientListContent.Wallets -> { - RecipientWalletListItem( - item = item, - clickIntents = clickIntents, - modifier = Modifier - .animateItemPlacement() - .padding(top = TangemTheme.dimens.spacing20) - .then( - if (index == 0) { - val bottomRadius = if (item.isWalletsOnly) { - TangemTheme.dimens.radius12 - } else { - TangemTheme.dimens.radius0 - } - Modifier.clip( - RoundedCornerShape( - topEnd = TangemTheme.dimens.radius12, - topStart = TangemTheme.dimens.radius12, - bottomStart = bottomRadius, - bottomEnd = bottomRadius, - ), - ) - } else { - Modifier - }, - ), - ) - } - is SendRecipientListContent.Item -> { - val title = item.title.resolveReference() - ListItemWithIcon( - title = item.title.resolveReference(), - subtitle = item.subtitle.resolveReference(), - info = item.timestamp?.resolveReference(), - subtitleEndOffset = item.subtitleEndOffset, - subtitleIconRes = item.subtitleIconRes, - modifier = Modifier - .then( - if (index == recipientList.itemCount - 1) { - Modifier - .padding(bottom = TangemTheme.dimens.spacing20) - .clip( - RoundedCornerShape( - bottomEnd = TangemTheme.dimens.radius12, - bottomStart = TangemTheme.dimens.radius12, - ), - ) - } else { - Modifier - }, - ) - .background(TangemTheme.colors.background.action), - onClick = { - clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress) + val item = list[index] + val title = item.title.resolveReference() + AnimatedVisibility( + visible = item.isVisible, + label = "Header Appearance Animation", + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + modifier = Modifier + .animateItemPlacement() + .animateContentSize(), + ) { + ListItemWithIcon( + title = title, + subtitle = item.subtitle.resolveReference(), + info = item.timestamp?.resolveReference(), + subtitleEndOffset = item.subtitleEndOffset, + subtitleIconRes = item.subtitleIconRes, + onClick = { clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress) }, + modifier = Modifier + .then( + if (isLast && index == list.lastIndex) { + Modifier + .padding(bottom = TangemTheme.dimens.spacing12) + .clip( + shape = RoundedCornerShape( + bottomStart = TangemTheme.dimens.radius16, + bottomEnd = TangemTheme.dimens.radius16, + ), + ) + } else { + Modifier }, ) - } - } - } - } -} - -@Composable -private fun RecipientWalletListItem( - item: SendRecipientListContent.Wallets, - clickIntents: SendClickIntents, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .background(TangemTheme.colors.background.action) - .padding(top = TangemTheme.dimens.spacing12), - ) { - if (item.list.isNotEmpty()) { - Text( - text = stringResource(R.string.send_recipient_wallets_title), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier - .fillMaxWidth() - .padding( - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - bottom = TangemTheme.dimens.spacing8, - ), - ) - } - item.list.forEachIndexed { _, wallet -> - val title = wallet.title.resolveReference() - ListItemWithIcon( - title = wallet.title.resolveReference(), - subtitle = wallet.subtitle.resolveReference(), - onClick = { clickIntents.onRecipientAddressValueChange(title, EnterAddressSource.RecentAddress) }, - ) - } - if (!item.isWalletsOnly) { - val topPadding = if (item.list.isNotEmpty()) { - TangemTheme.dimens.spacing8 - } else { - TangemTheme.dimens.spacing0 - } - Text( - text = stringResource(R.string.send_recent_transactions), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier - .fillMaxWidth() - .padding( - top = topPadding, - bottom = TangemTheme.dimens.spacing8, - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - ), + .background(TangemTheme.colors.background.action), ) } } 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 18252b0ed4..c35cf2ba2d 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 @@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.viewmodel import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.wallets.models.UserWalletId import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource +import com.tangem.features.send.impl.presentation.state.SendNotification import com.tangem.features.send.impl.presentation.state.fee.FeeType @Suppress("TooManyFunctions") @@ -33,7 +34,7 @@ internal interface SendClickIntents { // region Recipient fun onRecipientAddressValueChange(value: String, type: EnterAddressSource? = null) - fun onRecipientMemoValueChange(value: String, isPasted: Boolean = false) + fun onRecipientMemoValueChange(value: String) // endregion // region Fee @@ -43,7 +44,9 @@ internal interface SendClickIntents { fun onCustomFeeValueChange(index: Int, value: String) - fun onSubtractSelect(value: Boolean) + fun onSubtractSelect() + + fun onReadMoreClick() // endregion // region Send @@ -55,12 +58,14 @@ internal interface SendClickIntents { fun showFee() - fun onExploreClick(txUrl: String) + fun showSend() + + fun onExploreClick() fun onShareClick() - fun onAmountReduceClick(reducedAmount: String) + fun onAmountReduceClick(reducedAmount: String, clazz: Class) - fun onAmountReduceIgnoreClick() + fun onNotificationCancel(clazz: Class) // 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 fa912092d9..621edfae39 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 @@ -4,7 +4,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.* -import androidx.paging.PagingData import arrow.core.Either import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData @@ -27,8 +26,7 @@ import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase 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.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.models.UserWallet import com.tangem.domain.wallets.models.UserWalletId @@ -39,23 +37,21 @@ import com.tangem.features.send.impl.presentation.analytics.EnterAddressSource import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents import com.tangem.features.send.impl.presentation.analytics.SendScreenSource import com.tangem.features.send.impl.presentation.analytics.utils.SendOnNextScreenAnalyticSender -import com.tangem.features.send.impl.presentation.analytics.utils.SendRecipientAnalyticsSender import com.tangem.features.send.impl.presentation.domain.AvailableWallet import com.tangem.features.send.impl.presentation.state.* import com.tangem.features.send.impl.presentation.state.amount.AmountStateFactory -import com.tangem.features.send.impl.presentation.state.fee.FeeNotificationFactory -import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState -import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory -import com.tangem.features.send.impl.presentation.state.fee.FeeType +import com.tangem.features.send.impl.presentation.state.fee.* import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider 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.collections.immutable.toPersistentList import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import timber.log.Timber +import java.util.Locale import javax.inject.Inject import kotlin.properties.Delegates @@ -70,8 +66,7 @@ internal class SendViewModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getWalletsUseCase: GetWalletsUseCase, private val getCryptoCurrenciesUseCase: GetCryptoCurrenciesUseCase, - private val txHistoryItemsUseCase: GetTxHistoryItemsUseCase, - private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, + private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase, private val getFeeUseCase: GetFeeUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val createTransactionUseCase: CreateTransactionUseCase, @@ -104,7 +99,8 @@ internal class SendViewModel @Inject constructor( private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() private var innerRouter: InnerSendRouter by Delegates.notNull() - private var stateRouter: StateRouter by Delegates.notNull() + var stateRouter: StateRouter by Delegates.notNull() + private set private val stateFactory = SendStateFactory( clickIntents = this, @@ -124,7 +120,6 @@ internal class SendViewModel @Inject constructor( private val feeStateFactory = FeeStateFactory( clickIntents = this, currentStateProvider = Provider { uiState }, - coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isFeeApproximateUseCase = isFeeApproximateUseCase, @@ -141,8 +136,10 @@ internal class SendViewModel @Inject constructor( coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus }, currentStateProvider = Provider { uiState }, userWalletProvider = Provider { userWallet }, + stateRouterProvider = Provider { stateRouter }, clickIntents = this, getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, + analyticsEventHandler = analyticsEventHandler, ) private val sendNotificationFactory = SendNotificationFactory( @@ -150,16 +147,16 @@ internal class SendViewModel @Inject constructor( coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus }, currentStateProvider = Provider { uiState }, userWalletProvider = Provider { userWallet }, + stateRouterProvider = Provider { stateRouter }, currencyChecksRepository = currencyChecksRepository, clickIntents = this, + analyticsEventHandler = analyticsEventHandler, ) private val sendOnNextScreenAnalyticSender by lazy(LazyThreadSafetyMode.NONE) { SendOnNextScreenAnalyticSender(analyticsEventHandler) } - private val sendRecipientAnalyticsSender by lazy(LazyThreadSafetyMode.NONE) { - SendRecipientAnalyticsSender(analyticsEventHandler) - } + // todo convert to StateFlow var uiState: SendUiState by mutableStateOf(stateFactory.getInitialState()) private set @@ -170,6 +167,7 @@ internal class SendViewModel @Inject constructor( private var cryptoCurrencyStatus: CryptoCurrencyStatus by Delegates.notNull() private var balanceJobHolder = JobHolder() + private var balanceHidingJobHolder = JobHolder() private var recipientsJobHolder = JobHolder() private var feeJobHolder = JobHolder() private var addressValidationJobHolder = JobHolder() @@ -179,26 +177,34 @@ internal class SendViewModel @Inject constructor( private var sendIdleTimer = 0L + init { + subscribeOnCurrencyStatusUpdates() + subscribeOnBalanceHidden() + } + override fun onCreate(owner: LifecycleOwner) { - subscribeOnCurrencyStatusUpdates(owner) onStateActive() - subscribeOnBalanceHidden(owner) - analyticsEventHandler.send(SendAnalyticEvents.SendOpened) + } + + override fun onCleared() { + super.onCleared() + balanceHidingJobHolder.cancel() + balanceJobHolder.cancel() + stateRouter.clear() } fun setRouter(router: InnerSendRouter, stateRouter: StateRouter) { innerRouter = router this.stateRouter = stateRouter - uiState = uiState.copy(currentState = stateRouter.currentState) } - private fun subscribeOnCurrencyStatusUpdates(owner: LifecycleOwner) { + private fun subscribeOnCurrencyStatusUpdates() { viewModelScope.launch(dispatchers.main) { getUserWalletUseCase(userWalletId).fold( ifRight = { wallet -> userWallet = wallet checkIfSubtractAvailable() - getCurrenciesStatusUpdates(owner, wallet) + getCurrenciesStatusUpdates(wallet) }, ifLeft = { uiState = eventStateFactory.getGenericErrorState( @@ -210,23 +216,22 @@ internal class SendViewModel @Inject constructor( } } - private fun subscribeOnBalanceHidden(owner: LifecycleOwner) { + private fun subscribeOnBalanceHidden() { getBalanceHidingSettingsUseCase() - .flowWithLifecycle(owner.lifecycle) .conflate() .distinctUntilChanged() .onEach { uiState = stateFactory.getOnHideBalanceState(isBalanceHidden = it.isBalanceHidden) } .launchIn(viewModelScope) + .saveIn(balanceHidingJobHolder) } - private fun getCurrenciesStatusUpdates(owner: LifecycleOwner, wallet: UserWallet) { + private fun getCurrenciesStatusUpdates(wallet: UserWallet) { val isSingleWallet = wallet.scanResponse.walletData?.token != null && !wallet.isMultiCurrency if (cryptoCurrency is CryptoCurrency.Coin) { getCurrencyStatusUpdates(isSingleWallet = isSingleWallet) - .flowWithLifecycle(owner.lifecycle) .onEach { currencyStatus -> currencyStatus.onRight { onDataLoaded( @@ -249,7 +254,7 @@ internal class SendViewModel @Inject constructor( coinCurrencyStatus = coinStatus.getOrElse { error("Coin status is unreachable") }, ) } - }.flowWithLifecycle(owner.lifecycle) + } .flowOn(dispatchers.main) .launchIn(viewModelScope) .saveIn(balanceJobHolder) @@ -300,12 +305,10 @@ internal class SendViewModel @Inject constructor( combine( flow = getUserWallets().conflate(), flow2 = getTxHistory().conflate(), - flow3 = getTxHistoryCount().conflate(), - ) { wallets, txHistory, txHistoryCount -> - stateFactory.onLoadedRecipientList( + ) { wallets, txHistory -> + uiState = stateFactory.onLoadedRecipientList( wallets = wallets, txHistory = txHistory, - txHistoryCount = txHistoryCount, ) } .flowOn(dispatchers.io) @@ -322,7 +325,7 @@ internal class SendViewModel @Inject constructor( .filterNot { it.walletId == userWalletId || it.isLocked } .map { wallet -> async(dispatchers.io) { - getCryptoCurrenciesUseCase(wallet.walletId) + getCryptoCurrenciesUseCase.getSync(wallet.walletId) .fold( ifRight = { currencyItem -> val walletCurrency = currencyItem.firstOrNull { @@ -347,34 +350,20 @@ internal class SendViewModel @Inject constructor( } } - private fun getTxHistory(): Flow> { - return flow { - txHistoryItemsUseCase( - userWalletId = userWalletId, - currency = cryptoCurrency, - ).fold( - ifRight = { emitAll(it.distinctUntilChanged()) }, - ifLeft = {}, - ) - } - } - - private fun getTxHistoryCount(): Flow { - return flow { - txHistoryItemsCountUseCase( - userWalletId = userWalletId, - currency = cryptoCurrency, - ).fold( - ifRight = { emit(it) }, - ifLeft = { emit(0) }, - ) - } + private fun getTxHistory(): Flow> { + return getFixedTxHistoryItemsUseCase( + userWalletId = userWalletId, + currency = cryptoCurrency, + ).fold( + ifRight = { it.distinctUntilChanged() }, + ifLeft = { emptyFlow() }, + ) } private fun onStateActive() { - uiState.currentState + stateRouter.currentState .onEach { - when (it) { + when (it.type) { SendUiStateType.Fee -> loadFee() SendUiStateType.Send -> sendIdleTimer = System.currentTimeMillis() else -> Unit @@ -407,8 +396,29 @@ internal class SendViewModel @Inject constructor( override fun popBackStack() = stateRouter.popBackStack() override fun onBackClick() = stateRouter.onBackClick(uiState.sendState.isSuccess) override fun onNextClick() { - val prevScreen = stateRouter.onNextClick() - sendOnNextScreenAnalyticSender.send(prevScreen, uiState) + val currentState = stateRouter.currentState.value + val isCurrentFee = currentState.type == SendUiStateType.Fee + if (isCurrentFee) { + val isFeeCoverage = checkFeeCoverage(uiState, cryptoCurrencyStatus) + if (isAmountSubtractAvailable && isFeeCoverage) { + uiState = eventStateFactory.getFeeCoverageAlert( + onConsume = { uiState = eventStateFactory.onConsumeEventState() }, + ) + return + } else { + uiState = stateFactory.onSubtractSelect(false) + analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount(false)) + } + if (checkIfFeeTooLow(uiState)) { + uiState = eventStateFactory.getFeeTooLowAlert( + onConsume = { uiState = eventStateFactory.onConsumeEventState() }, + ) + return + } + } + + sendOnNextScreenAnalyticSender.send(currentState.type, uiState) + stateRouter.onNextClick() } override fun onPrevClick() = stateRouter.onPrevClick() @@ -428,7 +438,6 @@ internal class SendViewModel @Inject constructor( // region amount state clicks override fun onCurrencyChangeClick(isFiat: Boolean) { - analyticsEventHandler.send(SendAnalyticEvents.SwapCurrencyButtonClicked) uiState = amountStateFactory.getOnCurrencyChangedState(isFiat) } @@ -438,6 +447,7 @@ internal class SendViewModel @Inject constructor( override fun onMaxValueClick() { uiState = amountStateFactory.getOnMaxAmountClick() + analyticsEventHandler.send(SendAnalyticEvents.MaxAmountButtonClicked) } // endregion @@ -464,29 +474,30 @@ internal class SendViewModel @Inject constructor( uiState = stateFactory.getOnRecipientAddressValidationStarted() val isValidAddress = validateAddress(value) uiState = stateFactory.getOnRecipientAddressValidState(value, isValidAddress) - sendRecipientAnalyticsSender.sendAddressAnalytics(type, isValidAddress) + type?.let { analyticsEventHandler.send(SendAnalyticEvents.AddressEntered(it, isValidAddress)) } } }.saveIn(addressValidationJobHolder) } - override fun onRecipientMemoValueChange(value: String, isPasted: Boolean) { + override fun onRecipientMemoValueChange(value: String) { viewModelScope.launch(dispatchers.main) { if (!checkIfXrpAddressValue(value)) { uiState = stateFactory.getOnRecipientMemoValueChange(value) uiState = stateFactory.getOnRecipientAddressValidationStarted() val isValidAddress = validateAddress(uiState.recipientState?.addressTextField?.value.orEmpty()) uiState = stateFactory.getOnRecipientMemoValidState(value, isValidAddress) - sendRecipientAnalyticsSender.sendMemoAnalytics(isPasted) } }.saveIn(addressValidationJobHolder) } private suspend fun validateAddress(value: String): Boolean { - return validateWalletAddressUseCase( + val isValidAddress = validateWalletAddressUseCase( userWalletId = userWalletId, network = cryptoCurrency.network, address = value, ).getOrElse { false } + onEnteredValidAddress(isValidAddress) + return isValidAddress } private suspend fun checkIfXrpAddressValue(value: String): Boolean { @@ -498,6 +509,16 @@ internal class SendViewModel @Inject constructor( true } ?: false } + + private fun onEnteredValidAddress(isValidAddress: Boolean) { + val recipientState = uiState.recipientState ?: return + uiState = uiState.copy( + recipientState = recipientState.copy( + recent = recipientState.recent.map { it.copy(isVisible = !isValidAddress) }.toPersistentList(), + wallets = recipientState.wallets.map { it.copy(isVisible = !isValidAddress) }.toPersistentList(), + ), + ) + } // endregion // region fee @@ -516,21 +537,30 @@ internal class SendViewModel @Inject constructor( updateFeeNotifications() } - override fun onSubtractSelect(value: Boolean) { - uiState = feeStateFactory.onSubtractSelect(value) - updateFeeNotifications() + override fun onSubtractSelect() { + uiState = stateFactory.onSubtractSelect(true) + stateRouter.showSend() + analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount(true)) + } + + override fun onReadMoreClick() { + val locale = if (Locale.getDefault().language == RU_LOCALE) RU_LOCALE else EN_LOCALE + val url = buildString { + append(FEE_READ_MORE_URL_FIRST_PART) + append(locale) + append(FEE_READ_MORE_URL_SECOND_PART) + } + innerRouter.openUrl(url) } private fun loadFee() { viewModelScope.launch(dispatchers.main) { - uiState = feeStateFactory.onFeeOnLoadingState() + if (uiState.feeState?.fee == null) { + uiState = feeStateFactory.onFeeOnLoadingState() + } uiState = callFeeUseCase()?.fold( - ifRight = { fees -> - feeStateFactory.onFeeOnLoadedState(fees, isAmountSubtractAvailable) - }, - ifLeft = { - feeStateFactory.onFeeOnErrorState() - }, + ifRight = feeStateFactory::onFeeOnLoadedState, + ifLeft = { feeStateFactory.onFeeOnErrorState() }, ) ?: feeStateFactory.onFeeOnErrorState() updateFeeNotifications() }.saveIn(feeJobHolder) @@ -569,52 +599,55 @@ internal class SendViewModel @Inject constructor( onCheckFeeUpdate() } sendIdleTimer = System.currentTimeMillis() - analyticsEventHandler.send(SendAnalyticEvents.SendButtonClicked) } override fun showAmount() { - stateRouter.showAmount() + stateRouter.showAmount(isFromConfirmation = true) analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount)) } override fun showRecipient() { - stateRouter.showRecipient() + stateRouter.showRecipient(isFromConfirmation = true) analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address)) } override fun showFee() { - stateRouter.showFee() + stateRouter.showFee(isFromConfirmation = true) analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Fee)) } - override fun onExploreClick(txUrl: String) { + override fun showSend() { + stateRouter.showSend() + } + + override fun onExploreClick() { analyticsEventHandler.send(SendAnalyticEvents.ExploreButtonClicked) - innerRouter.openUrl(txUrl) + innerRouter.openUrl(uiState.sendState.txUrl) } override fun onShareClick() { analyticsEventHandler.send(SendAnalyticEvents.ShareButtonClicked) } - override fun onAmountReduceClick(reducedAmount: String) { + override fun onAmountReduceClick(reducedAmount: String, clazz: Class) { uiState = amountStateFactory.getOnAmountValueChange(reducedAmount) - uiState = sendNotificationFactory.dismissHighFeeWarningState() + uiState = sendNotificationFactory.dismissNotificationState(clazz) loadFee() } - override fun onAmountReduceIgnoreClick() { - uiState = sendNotificationFactory.dismissHighFeeWarningState() + override fun onNotificationCancel(clazz: Class) { + uiState = sendNotificationFactory.dismissNotificationState(clazz) } private fun verifyAndSendTransaction() { val recipient = uiState.recipientState?.addressTextField?.value ?: return val feeState = uiState.feeState ?: return - val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return + val fee = feeState.fee ?: return val memo = uiState.recipientState?.memoTextField?.value - val fee = feeStateFactory.feeConverter.convert(feeSelectorState) val amountValue = uiState.amountState?.amountTextField?.cryptoAmount?.value ?: return - val amountToSend = if (feeState.isSubtract && isAmountSubtractAvailable) { - feeState.receivedAmountValue + val amountToSend = if (uiState.sendState.isSubtract && isAmountSubtractAvailable) { + val feeValue = fee.amount.value ?: return + amountValue.minus(feeValue) } else { amountValue } @@ -655,6 +688,7 @@ internal class SendViewModel @Inject constructor( error = error, onConsume = { uiState = eventStateFactory.onConsumeEventState() }, ) + analyticsEventHandler.send(SendAnalyticEvents.TransactionError(cryptoCurrency.symbol)) }, ifRight = { uiState = stateFactory.getSendingStateUpdate(isSending = false) @@ -677,11 +711,10 @@ internal class SendViewModel @Inject constructor( } private fun onCheckFeeUpdate() { - val isSending = uiState.sendState.isSending val isSuccess = uiState.sendState.isSuccess val noErrorNotifications = uiState.sendState.notifications.none { it is SendNotification.Error } - if (!isSending && !isSuccess && noErrorNotifications) { + if (!isSuccess && noErrorNotifications) { viewModelScope.launch(dispatchers.main) { val feeUpdatedState = callFeeUseCase()?.fold( ifRight = { @@ -720,5 +753,10 @@ internal class SendViewModel @Inject constructor( companion object { private const val CHECK_FEE_UPDATE_DELAY = 60_000L private const val BALANCE_UPDATE_DELAY = 10_000L + + private const val RU_LOCALE = "ru" + private const val EN_LOCALE = "en" + private const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/" + private const val FEE_READ_MORE_URL_SECOND_PART = "/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/" } } \ No newline at end of file diff --git a/features/swap/api/build.gradle.kts b/features/swap/api/build.gradle.kts index 882b05bd82..76bb25817f 100644 --- a/features/swap/api/build.gradle.kts +++ b/features/swap/api/build.gradle.kts @@ -7,6 +7,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.feature.swap.api" +} + dependencies { /** DI */ implementation(deps.hilt.android) diff --git a/features/swap/api/src/main/AndroidManifest.xml b/features/swap/api/src/main/AndroidManifest.xml deleted file mode 100644 index b46e5ddb0b..0000000000 --- a/features/swap/api/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/features/swap/data/build.gradle.kts b/features/swap/data/build.gradle.kts index 12693598d2..208635ebbd 100644 --- a/features/swap/data/build.gradle.kts +++ b/features/swap/data/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.feature.swap.data" +} + dependencies { /** AndroidX */ diff --git a/features/swap/data/src/main/AndroidManifest.xml b/features/swap/data/src/main/AndroidManifest.xml deleted file mode 100644 index 5981b0aeee..0000000000 --- a/features/swap/data/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ 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 index 3499d36f4c..e360f8bfad 100644 --- 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 @@ -152,7 +152,7 @@ class DefaultSwapTransactionRepository( key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, ) - val updatesMap = savedMap?.toMutableMap() ?: mutableMapOf() + val updatesMap = savedMap.toMutableMap() updatesMap[txId] = status mutablePreferences.setObjectMap( @@ -232,9 +232,9 @@ class DefaultSwapTransactionRepository( val savedList = mutablePreferences.getObjectMap( key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY, ) - val editedList = savedList?.filterNot { it.key == txId } + val editedList = savedList.filterNot { it.key == txId } - if (editedList.isNullOrEmpty()) { + if (editedList.isEmpty()) { mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY) } else { mutablePreferences.setObjectMap( diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt index 1e3727b208..a422c02284 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/ExchangeStatus.kt @@ -5,6 +5,7 @@ data class ExchangeStatusModel( val status: ExchangeStatus? = null, val txId: String? = null, val txExternalUrl: String? = null, + val txExternalId: String? = null, ) enum class ExchangeStatus { diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt index fee839273d..383d8fe28f 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt @@ -3,5 +3,4 @@ package com.tangem.feature.swap.domain.models.domain data class NetworkInfo( val name: String, val blockchainId: String, - val blockchainCurrency: String, ) \ No newline at end of file diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt index ca44dc046e..ec5293ae96 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt @@ -7,19 +7,18 @@ import com.tangem.feature.swap.domain.models.SwapAmount * * @property isAllowedToSpend shows is token allowed to spend * @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, + val feeState: SwapFeeState, val hasOutgoingTransaction: Boolean, val includeFeeInAmount: IncludeFeeInAmount, ) sealed class IncludeFeeInAmount { data class Included(val amountSubtractFee: SwapAmount) : IncludeFeeInAmount() - object Excluded : IncludeFeeInAmount() - object BalanceNotEnough : IncludeFeeInAmount() + data object Excluded : IncludeFeeInAmount() + data object BalanceNotEnough : IncludeFeeInAmount() } \ No newline at end of file diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt new file mode 100644 index 0000000000..5777155325 --- /dev/null +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt @@ -0,0 +1,12 @@ +package com.tangem.feature.swap.domain.models.domain + +import com.tangem.domain.tokens.model.CryptoCurrency + +sealed class SwapFeeState { + data object Enough : SwapFeeState() + data class NotEnough( + val feeCurrency: CryptoCurrency? = null, + val currencyName: String? = null, + val currencySymbol: String? = null, + ) : SwapFeeState() +} \ No newline at end of file diff --git a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 1fae322ed1..f5fa03d4e2 100644 --- a/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/models/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -3,10 +3,7 @@ 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 com.tangem.feature.swap.domain.models.domain.Warning +import com.tangem.feature.swap.domain.models.domain.* import java.math.BigDecimal sealed interface SwapState { @@ -15,11 +12,10 @@ sealed interface SwapState { val fromTokenInfo: TokenSwapInfo, val toTokenInfo: TokenSwapInfo, val priceImpact: PriceImpact, - val networkCurrency: String, val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState( isAllowedToSpend = false, isBalanceEnough = false, - isFeeEnough = false, + feeState = SwapFeeState.NotEnough(), hasOutgoingTransaction = false, includeFeeInAmount = IncludeFeeInAmount.Excluded, ), @@ -98,7 +94,7 @@ sealed class TxFeeState { val fee: TxFee, ) : TxFeeState() - object Empty : TxFeeState() + data object Empty : TxFeeState() } data class TxFee( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt index ea6207fece..284b1572cb 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultBlockchainInteractor.kt @@ -13,7 +13,6 @@ internal class DefaultBlockchainInteractor @Inject constructor( NetworkInfo( name = it.name, blockchainId = it.blockchainId, - blockchainCurrency = it.blockchainCurrency, ) } } 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 1d90e85471..c1a72132a4 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 @@ -13,7 +13,9 @@ import com.tangem.domain.appcurrency.repository.AppCurrencyRepository 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.FeePaidCurrency import com.tangem.domain.tokens.model.Quote +import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.tokens.repository.QuotesRepository import com.tangem.domain.tokens.utils.convertToAmount @@ -61,6 +63,7 @@ internal class SwapInteractorImpl @Inject constructor( private val swapTransactionRepository: SwapTransactionRepository, private val currencyChecksRepository: CurrencyChecksRepository, private val appCurrencyRepository: AppCurrencyRepository, + private val currenciesRepository: CurrenciesRepository, private val initialToCurrencyResolver: InitialToCurrencyResolver, ) : SwapInteractor { @@ -251,7 +254,7 @@ internal class SwapInteractorImpl @Inject constructor( if (amountDecimal == null || amountDecimal.signum() == 0) { return providers.associateWith { createEmptyAmountState() } } - val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency)) + val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) val networkId = fromToken.currency.network.backendId when (provider.type) { @@ -433,7 +436,7 @@ internal class SwapInteractorImpl @Inject constructor( return when (swapProvider.type) { ExchangeProviderType.CEX -> { val amountDecimal = toBigDecimalOrNull(amountToSwap) - val amount = SwapAmount(requireNotNull(amountDecimal), getTokenDecimals(currencyToSend.currency)) + val amount = SwapAmount(requireNotNull(amountDecimal), currencyToSend.currency.decimals) val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { includeFeeInAmount.amountSubtractFee } else { @@ -471,17 +474,28 @@ internal class SwapInteractorImpl @Inject constructor( if (amountDecimal == null || amountDecimal.signum() == 0) { return state } - val amount = SwapAmount(amountDecimal, getTokenDecimals(fromToken.currency)) + val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) val includeFeeInAmount = getIncludeFeeInAmount( networkId = fromToken.currency.network.backendId, txFee = state.txFee, amount = amount, fromToken = fromToken.currency, ) + val fee = when (val txFee = state.txFee) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue + is TxFeeState.SingleFeeState -> txFee.fee.feeValue + } + val feeState = getFeeState( + fee = fee, + spendAmount = amount, + networkId = fromToken.currency.network.backendId, + fromTokenStatus = fromToken, + ) return state.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = state.preparedSwapConfigState.copy( - isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, + feeState = feeState, isBalanceEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, includeFeeInAmount = includeFeeInAmount, ), @@ -497,7 +511,7 @@ internal class SwapInteractorImpl @Inject constructor( fee: TxFee, ): TxState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } - val amount = SwapAmount(amountDecimal, getTokenDecimals(currencyToSend)) + val amount = SwapAmount(amountDecimal, currencyToSend.decimals) val derivationPath = currencyToSend.network.derivationPath.value val result = transactionManager.sendTransaction( txData = SwapTxData( @@ -592,7 +606,7 @@ internal class SwapInteractorImpl @Inject constructor( network = currencyToSend.currency.network, ) - val externalUrl = (exchangeData.transaction as? ExpressTransactionModel.CEX)?.externalTxUrl + val txCexModel = exchangeData.transaction as? ExpressTransactionModel.CEX val derivationPath = currencyToSend.currency.network.derivationPath.value return result.fold( @@ -607,6 +621,7 @@ internal class SwapInteractorImpl @Inject constructor( }, ifRight = { val timestamp = System.currentTimeMillis() + val txExternalUrl = txCexModel?.externalTxUrl storeSwapTransaction( currencyToSend = currencyToSend, currencyToGet = currencyToGet, @@ -614,7 +629,8 @@ internal class SwapInteractorImpl @Inject constructor( swapProvider = swapProvider, swapDataModel = exchangeData, timestamp = timestamp, - txExternalUrl = externalUrl.orEmpty(), + txExternalUrl = txExternalUrl.orEmpty(), + txExternalId = txCexModel?.externalTxId.orEmpty(), ) storeLastCryptoCurrencyId(currencyToGet.currency) TxState.TxSent( @@ -632,7 +648,7 @@ internal class SwapInteractorImpl @Inject constructor( currencyToSend.currency.network.backendId, derivationPath, ).orEmpty(), - txExternalUrl = externalUrl, + txExternalUrl = txExternalUrl, timestamp = timestamp, ) }, @@ -683,6 +699,7 @@ internal class SwapInteractorImpl @Inject constructor( swapDataModel: SwapDataModel, timestamp: Long, txExternalUrl: String, + txExternalId: String, ) { swapTransactionRepository.storeTransaction( userWalletId = UserWalletId(userWalletManager.getWalletId()), @@ -699,6 +716,7 @@ internal class SwapInteractorImpl @Inject constructor( status = ExchangeStatus.New, txId = swapDataModel.transaction.txId, txExternalUrl = txExternalUrl, + txExternalId = txExternalId, ), ), ) @@ -713,7 +731,7 @@ internal class SwapInteractorImpl @Inject constructor( @Deprecated("used in old swap mechanism") override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount { - return SwapAmount(token.value.amount ?: BigDecimal.ZERO, getTokenDecimals(token.currency)) + return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals) } @Deprecated("used in old swap mechanism") @@ -734,14 +752,6 @@ internal class SwapInteractorImpl @Inject constructor( return repository.getNativeTokenForNetwork(networkId) } - private fun getTokenDecimals(token: CryptoCurrency): Int { - return if (token is CryptoCurrency.Token) { - token.decimals - } else { - transactionManager.getNativeTokenDecimals(token.network.backendId) - } - } - private suspend fun isAllowedToSpend( networkId: String, fromToken: CryptoCurrency, @@ -755,7 +765,7 @@ internal class SwapInteractorImpl @Inject constructor( userWalletId = userWallet.walletId, networkId = networkId, derivationPath = fromToken.network.derivationPath.value, - tokenDecimalCount = getTokenDecimals(fromToken), + tokenDecimalCount = fromToken.decimals, tokenAddress = getTokenAddress(fromToken), spenderAddress = spenderAddress, ) @@ -797,7 +807,7 @@ internal class SwapInteractorImpl @Inject constructor( val toToken = toTokenStatus.currency return coroutineScope { val txFee = if (provider.type == ExchangeProviderType.CEX) { - getFeeForCex(amount, fromTokenStatus, networkId) + getFeeForCex(amount, fromTokenStatus) } else { TxFeeState.Empty } @@ -885,10 +895,21 @@ internal class SwapInteractorImpl @Inject constructor( ) } ExchangeProviderType.CEX -> { + val fee = when (txFee) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue + is TxFeeState.SingleFeeState -> txFee.fee.feeValue + } + val feeState = getFeeState( + fee = fee, + spendAmount = amount, + networkId = networkId, + fromTokenStatus = fromToken, + ) swapState.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = PreparedSwapConfigState( - isFeeEnough = includeFeeInAmount !is IncludeFeeInAmount.BalanceNotEnough, + feeState = feeState, isAllowedToSpend = isAllowedToSpend, isBalanceEnough = isBalanceWithoutFeeEnough, hasOutgoingTransaction = hasOutgoingTransaction(fromToken), @@ -911,26 +932,47 @@ internal class SwapInteractorImpl @Inject constructor( ) } + @Suppress("CyclomaticComplexMethod") private suspend fun getIncludeFeeInAmount( networkId: String, txFee: TxFeeState, amount: SwapAmount, fromToken: CryptoCurrency, + ): IncludeFeeInAmount { + val feeValue = when (txFee) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue + is TxFeeState.SingleFeeState -> txFee.fee.feeValue + } + val feePaidCurrency = getFeePaidCurrency( + userWalletId = requireNotNull(getSelectedWallet()).walletId, + currency = fromToken, + ) + + return when (feePaidCurrency) { + is FeePaidCurrency.Token -> { + if (feePaidCurrency.balance > feeValue) { + IncludeFeeInAmount.Excluded + } else { + IncludeFeeInAmount.BalanceNotEnough + } + } + else -> getIncludeFeeAmountForCoinFee(networkId, amount, feeValue, fromToken) + } + } + + private suspend fun getIncludeFeeAmountForCoinFee( + networkId: String, + amount: SwapAmount, + feeValue: BigDecimal, + fromToken: CryptoCurrency, ): IncludeFeeInAmount { val tokenForFeeBalance = userWalletManager.getNativeTokenBalance( networkId, fromToken.network.derivationPath.value, ) ?: ProxyAmount.empty() - - val feeValue = when (txFee) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.priorityFee.feeValue - is TxFeeState.SingleFeeState -> txFee.fee.feeValue - } - val amountWithFee = amount.value + feeValue - return when { fromToken is CryptoCurrency.Token -> { if (feeValue > tokenForFeeBalance.value || tokenForFeeBalance.value.signum() == 0) { @@ -950,7 +992,7 @@ internal class SwapInteractorImpl @Inject constructor( IncludeFeeInAmount.Included( SwapAmount( tokenForFeeBalance.value - feeValue, - transactionManager.getNativeTokenDecimals(networkId), + getNativeToken(fromToken.network.backendId).decimals, ), ) } else { @@ -960,11 +1002,18 @@ internal class SwapInteractorImpl @Inject constructor( } } - private suspend fun getFormattedFiatFees(networkId: String, vararg fees: BigDecimal): List { + private suspend fun getFormattedFiatFees(fromToken: CryptoCurrency, vararg fees: BigDecimal): List { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() - val nativeToken = repository.getNativeTokenForNetwork(networkId) - val rates = getQuotes(nativeToken.id) - return rates[nativeToken.id]?.fiatRate?.let { rate -> + val feePaidCurrency = getFeePaidCurrency( + userWalletId = requireNotNull(getSelectedWallet()).walletId, + currency = fromToken, + ) + val feeCurrencyId: CryptoCurrency.ID = when (feePaidCurrency) { + is FeePaidCurrency.Token -> feePaidCurrency.tokenId + else -> getNativeToken(networkId = fromToken.network.backendId).id + } + val rates = getQuotes(feeCurrencyId) + return rates[feeCurrencyId]?.fiatRate?.let { rate -> fees.map { fee -> fee.toFiatString(rate, appCurrency.symbol, true) } @@ -1006,16 +1055,16 @@ internal class SwapInteractorImpl @Inject constructor( derivationPath = fromToken.currency.network.derivationPath.value, ) val txFeeState = when (feeData) { - is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId) - is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId) + is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken.currency) + is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken.currency) } val feeByPriority = selectFeeByType(feeType = selectedFee, txFeeState = txFeeState) val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeByPriority) - val isFeeEnough = checkFeeIsEnough( + val feeState = getFeeState( fee = feeByPriority, spendAmount = amount, networkId = networkId, - fromToken = fromToken.currency, + fromTokenStatus = fromToken, ) val swapState = updateBalances( networkId = networkId, @@ -1032,7 +1081,7 @@ internal class SwapInteractorImpl @Inject constructor( preparedSwapConfigState = PreparedSwapConfigState( isAllowedToSpend = true, isBalanceEnough = isBalanceIncludeFeeEnough, - isFeeEnough = isFeeEnough, + feeState = feeState, hasOutgoingTransaction = hasOutgoingTransaction(fromToken), includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex ), @@ -1089,17 +1138,12 @@ internal class SwapInteractorImpl @Inject constructor( toTokenAmount = toTokenAmount.value, toRate = rates[toToken.id]?.fiatRate?.toDouble() ?: 0.0, ), - networkCurrency = userWalletManager.getNetworkCurrency(networkId), swapDataModel = swapData, txFee = txFeeState, ) } - private suspend fun getFeeForCex( - amount: SwapAmount, - fromToken: CryptoCurrencyStatus, - networkId: String, - ): TxFeeState { + private suspend fun getFeeForCex(amount: SwapAmount, fromToken: CryptoCurrencyStatus): TxFeeState { getSelectedWalletSyncUseCase().getOrNull()?.walletId?.let { userWalletId -> val txFeeResult = estimateFeeUseCase( amount = amount.value, @@ -1111,7 +1155,7 @@ internal class SwapInteractorImpl @Inject constructor( TxFeeState.Empty }, ifRight = { txFee -> - txFee.toTxFeeState(networkId) + txFee.toTxFeeState(fromToken.currency) }, ) ?: TxFeeState.Empty } @@ -1170,8 +1214,8 @@ internal class SwapInteractorImpl @Inject constructor( } val feeState = feeData?.let { when (feeData) { - is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(networkId) - is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(networkId) + is ProxyFees.MultipleFees -> feeData.proxyFeesToFeeState(fromToken) + is ProxyFees.SingleFee -> feeData.proxyFeesToFeeState(fromToken) } } ?: TxFeeState.Empty val fee = when (feeState) { @@ -1179,11 +1223,11 @@ internal class SwapInteractorImpl @Inject constructor( is TxFeeState.MultipleFeeState -> feeState.normalFee.feeValue is TxFeeState.SingleFeeState -> feeState.fee.feeValue } - val isFeeEnough = checkFeeIsEnough( + val swapFeeState = getFeeState( fee = fee, spendAmount = SwapAmount.zeroSwapAmount(), networkId = networkId, - fromToken = fromToken, + fromTokenStatus = fromTokenStatus, ) return quotesLoadedState.copy( permissionState = PermissionDataState.PermissionReadyForRequest( @@ -1199,28 +1243,26 @@ internal class SwapInteractorImpl @Inject constructor( ), ), preparedSwapConfigState = quotesLoadedState.preparedSwapConfigState.copy( - isFeeEnough = isFeeEnough, + feeState = swapFeeState, ), ) } - private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(networkId: String): TxFeeState { + private suspend fun ProxyFees.MultipleFees.proxyFeesToFeeState(fromToken: CryptoCurrency): 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 feesFiat = getFormattedFiatFees(fromToken, 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 = decimals, + decimals = minFee.fee.decimals, ) val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = priorityFeeValue, - decimals = decimals, + decimals = normalFee.fee.decimals, ) return TxFeeState.MultipleFeeState( normalFee = TxFee( @@ -1228,8 +1270,8 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = normalFeeGas, feeFiatFormatted = normalFiatFee, feeCryptoFormatted = normalCryptoFee, - decimals = decimals, - cryptoSymbol = networkCurrency, + decimals = minFee.fee.decimals, + cryptoSymbol = minFee.fee.currencySymbol, feeType = FeeType.NORMAL, ), priorityFee = TxFee( @@ -1237,23 +1279,21 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = priorityFeeGas, feeFiatFormatted = priorityFiatFee, feeCryptoFormatted = priorityCryptoFee, - decimals = decimals, - cryptoSymbol = networkCurrency, + decimals = normalFee.fee.decimals, + cryptoSymbol = normalFee.fee.currencySymbol, feeType = FeeType.PRIORITY, ), ) } - private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState(networkId: String): TxFeeState { + private suspend fun ProxyFees.SingleFee.proxyFeesToFeeState(fromToken: CryptoCurrency): TxFeeState { val normalFeeValue = this.singleFee.fee.value val normalFeeGas = this.singleFee.gasLimit.toInt() - val networkCurrency = userWalletManager.getNetworkCurrency(networkId) - val feesFiat = getFormattedFiatFees(networkId, normalFeeValue) + val feesFiat = getFormattedFiatFees(fromToken, 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, + decimals = singleFee.fee.decimals, ) return TxFeeState.SingleFeeState( fee = TxFee( @@ -1261,32 +1301,30 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = normalFeeGas, feeFiatFormatted = normalFiatFee, feeCryptoFormatted = normalCryptoFee, - decimals = decimals, - cryptoSymbol = networkCurrency, + decimals = singleFee.fee.decimals, + cryptoSymbol = singleFee.fee.currencySymbol, feeType = FeeType.NORMAL, ), ) } - private suspend fun TransactionFee.toTxFeeState(networkId: String): TxFeeState { - val networkCurrency = userWalletManager.getNetworkCurrency(networkId) - val decimals = transactionManager.getNativeTokenDecimals(networkId) + private suspend fun TransactionFee.toTxFeeState(fromToken: CryptoCurrency): TxFeeState { return when (this) { is TransactionFee.Choosable -> { val normalFee = this.normal.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND) val priorityFee = this.priority.increaseGasLimitBy(INCREASE_GAS_LIMIT_FOR_SEND) val feeNormal = normalFee.amount.value ?: BigDecimal.ZERO val feePriority = priorityFee.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0] - val priorityFiatValue = getFormattedFiatFees(networkId, feePriority)[0] + val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] + val priorityFiatValue = getFormattedFiatFees(fromToken, feePriority)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, - decimals = decimals, + decimals = normalFee.amount.decimals, ) val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feePriority, - decimals = decimals, + decimals = priorityFee.amount.decimals, ) TxFeeState.MultipleFeeState( normalFee = TxFee( @@ -1294,8 +1332,8 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = normalFee.getGasLimit(), feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, - decimals = decimals, - cryptoSymbol = networkCurrency, + decimals = normalFee.amount.decimals, + cryptoSymbol = normalFee.amount.currencySymbol, feeType = FeeType.NORMAL, ), priorityFee = TxFee( @@ -1303,18 +1341,18 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = priorityFee.getGasLimit(), feeFiatFormatted = priorityFiatValue, feeCryptoFormatted = priorityCryptoFee, - decimals = decimals, - cryptoSymbol = networkCurrency, + decimals = priorityFee.amount.decimals, + cryptoSymbol = priorityFee.amount.currencySymbol, feeType = FeeType.PRIORITY, ), ) } is TransactionFee.Single -> { val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(networkId, feeNormal)[0] + val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, - decimals = transactionManager.getNativeTokenDecimals(networkId), + decimals = this.normal.amount.decimals, ) TxFeeState.SingleFeeState( fee = TxFee( @@ -1322,8 +1360,8 @@ internal class SwapInteractorImpl @Inject constructor( gasLimit = this.normal.getGasLimit(), feeFiatFormatted = normalFiatValue, feeCryptoFormatted = normalCryptoFee, - decimals = decimals, - cryptoSymbol = networkCurrency, + decimals = normal.amount.decimals, + cryptoSymbol = normal.amount.currencySymbol, feeType = FeeType.NORMAL, ), ) @@ -1375,15 +1413,35 @@ internal class SwapInteractorImpl @Inject constructor( } } - private fun isBalanceEnough(fromToken: CryptoCurrencyStatus, amount: SwapAmount, fee: BigDecimal?): Boolean { + private suspend 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) + val feePaidCurrency = getFeePaidCurrency( + userWalletId = requireNotNull(getSelectedWallet()).walletId, + currency = fromToken.currency, + ) + return when (feePaidCurrency) { + is FeePaidCurrency.Token -> tokenBalance >= amount.value + else -> { + if (fromToken.currency is CryptoCurrency.Token) { + tokenBalance >= amount.value + } else { + tokenBalance > amount.value.plus(fee ?: BigDecimal.ZERO) + } + } } } + private suspend fun getFeePaidCurrency(userWalletId: UserWalletId, currency: CryptoCurrency): FeePaidCurrency { + return currenciesRepository.getFeePaidCurrency( + userWalletId = userWalletId, + currency = currency, + ) + } + private suspend fun getWalletAddress(networkId: String, derivationPath: String?): String { return userWalletManager.getWalletAddress(networkId, derivationPath) } @@ -1399,36 +1457,72 @@ internal class SwapInteractorImpl @Inject constructor( } } - private suspend fun checkFeeIsEnough( + private fun toBigDecimalOrNull(amountToSwap: String): BigDecimal? { + return amountToSwap.replace(",", ".").toBigDecimalOrNull() + } + + private suspend fun getFeeState( fee: BigDecimal?, spendAmount: SwapAmount, networkId: String, - fromToken: CryptoCurrency, - ): Boolean { + fromTokenStatus: CryptoCurrencyStatus, + ): SwapFeeState { + val userWalletId = requireNotNull(getSelectedWallet()).walletId if (fee == null) { - return false + return SwapFeeState.NotEnough() } - val nativeTokenBalance = userWalletManager.getNativeTokenBalance( - networkId, - fromToken.network.derivationPath.value, - ) - val percentsToFeeIncrease = BigDecimal.ONE - return when (fromToken) { - is CryptoCurrency.Coin -> { - nativeTokenBalance?.let { balance -> - return balance.value.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease) - } ?: false - } - is CryptoCurrency.Token -> { - nativeTokenBalance?.let { balance -> - return balance.value > fee.multiply(percentsToFeeIncrease) - } ?: false - } - } - } - private fun toBigDecimalOrNull(amountToSwap: String): BigDecimal? { - return amountToSwap.replace(",", ".").toBigDecimalOrNull() + val percentsToFeeIncrease = BigDecimal.ONE + return when (val feePaidCurrency = getFeePaidCurrency(userWalletId, fromTokenStatus.currency)) { + FeePaidCurrency.Coin -> { + val nativeTokenBalance = userWalletManager.getNativeTokenBalance( + networkId, + fromTokenStatus.currency.network.derivationPath.value, + ) + nativeTokenBalance?.let { balance -> + if (balance.value.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)) { + SwapFeeState.Enough + } else { + val nativeToken = getNativeToken(fromTokenStatus.currency.network.backendId) + SwapFeeState.NotEnough( + feeCurrency = nativeToken, + currencyName = nativeToken.network.name, + currencySymbol = nativeToken.symbol, + ) + } + } ?: SwapFeeState.NotEnough() + } + FeePaidCurrency.SameCurrency -> { + val balance = fromTokenStatus.value.amount ?: return SwapFeeState.NotEnough() + if (balance.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)) { + SwapFeeState.Enough + } else { + SwapFeeState.NotEnough( + feeCurrency = fromTokenStatus.currency, + currencyName = fromTokenStatus.currency.network.name, + currencySymbol = fromTokenStatus.currency.symbol, + ) + } + } + is FeePaidCurrency.Token -> { + if (feePaidCurrency.balance > fee.multiply(percentsToFeeIncrease)) { + SwapFeeState.Enough + } else { + val token = currenciesRepository + .getMultiCurrencyWalletCurrenciesSync(userWalletId) + .find { + it is CryptoCurrency.Token && + it.contractAddress.equals(feePaidCurrency.contractAddress, ignoreCase = true) && + it.network.derivationPath == fromTokenStatus.currency.network.derivationPath + } + SwapFeeState.NotEnough( + feeCurrency = token, + currencyName = feePaidCurrency.name, + currencySymbol = feePaidCurrency.symbol, + ) + } + } + } } private fun calculatePriceImpact( 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 b1fef42180..196d529e7b 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 @@ -47,6 +47,7 @@ class SwapDomainModule { walletManagersFacade: WalletManagersFacade, coroutineDispatcherProvider: CoroutineDispatcherProvider, initialToCurrencyResolver: InitialToCurrencyResolver, + currenciesRepository: CurrenciesRepository, ): SwapInteractor { return SwapInteractorImpl( transactionManager = transactionManager, @@ -62,6 +63,7 @@ class SwapDomainModule { swapTransactionRepository = swapTransactionRepository, appCurrencyRepository = appCurrencyRepository, currencyChecksRepository = currencyChecksRepository, + currenciesRepository = currenciesRepository, initialToCurrencyResolver = initialToCurrencyResolver, ) } diff --git a/features/swap/presentation/build.gradle.kts b/features/swap/presentation/build.gradle.kts index a08a338a18..8986bf7e0d 100644 --- a/features/swap/presentation/build.gradle.kts +++ b/features/swap/presentation/build.gradle.kts @@ -7,6 +7,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.feature.swap.presentation" +} + dependencies { /** Core modules */ implementation(projects.core.analytics) diff --git a/features/swap/presentation/src/main/AndroidManifest.xml b/features/swap/presentation/src/main/AndroidManifest.xml deleted file mode 100644 index 927ad53a71..0000000000 --- a/features/swap/presentation/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file 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 71df4eb5d3..32b1388ef6 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 @@ -15,7 +15,6 @@ import com.tangem.feature.swap.models.states.ProviderState data class SwapStateHolder( val sendCardData: SwapCardState, val receiveCardData: SwapCardState, - val networkCurrency: String, val blockchainId: String, // not the same as networkId, its local id in app val warnings: List = emptyList(), val alert: SwapWarning.GenericWarning? = null, 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 abeec20c38..e7641eca69 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,5 +1,6 @@ package com.tangem.feature.swap.models +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.feature.swap.domain.models.ui.TxFee data class UiActions( @@ -20,7 +21,7 @@ data class UiActions( val onSelectFeeType: (TxFee) -> Unit, val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, - val onBuyClick: () -> Unit, + val onBuyClick: (CryptoCurrency) -> Unit, val onPolicyClick: (String) -> Unit, val onTosClick: (String) -> Unit, val onReceiveCardWarningClick: () -> Unit, 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 index 56ca1cbc5b..d139a7b75f 100644 --- 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 @@ -27,8 +27,8 @@ sealed class ProviderState { val subtitle: TextReference, val selectionType: SelectionType, val additionalBadge: AdditionalBadge, + val percentLowerThenBest: PercentDifference = PercentDifference.Empty, val namePrefix: PrefixType, - val percentLowerThenBest: PercentLowerThanBest = PercentLowerThanBest.Empty, override val onProviderClick: (String) -> Unit, ) : ProviderState() @@ -61,9 +61,9 @@ sealed class ProviderState { } @Immutable -sealed class PercentLowerThanBest { - data class Value(val value: Float) : PercentLowerThanBest() - object Empty : PercentLowerThanBest() +sealed class PercentDifference { + data class Value(val value: Float) : PercentDifference() + object Empty : PercentDifference() } object ProviderPercentDiffComparator : Comparator { @@ -77,14 +77,14 @@ object ProviderPercentDiffComparator : Comparator { if (o1 is ProviderState.Content && o2 is ProviderState.Content) { val o1Percent = o1.percentLowerThenBest val o2Percent = o2.percentLowerThenBest - if (o1Percent is PercentLowerThanBest.Value && o2Percent !is PercentLowerThanBest.Value) { + if (o1Percent is PercentDifference.Value && o2Percent !is PercentDifference.Value) { return -1 } - if (o1Percent !is PercentLowerThanBest.Value && o2Percent is PercentLowerThanBest.Value) { + if (o1Percent !is PercentDifference.Value && o2Percent is PercentDifference.Value) { return 1 } - return if (o1Percent is PercentLowerThanBest.Value && o2Percent is PercentLowerThanBest.Value) { - o1Percent.value.compareTo(o2Percent.value) + return if (o1Percent is PercentDifference.Value && o2Percent is PercentDifference.Value) { + o2Percent.value.compareTo(o1Percent.value) } else { 0 } diff --git a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt index c9c56536e8..0033a1971b 100644 --- a/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt +++ b/features/swap/presentation/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -31,12 +30,13 @@ import com.tangem.core.ui.res.TangemTheme @Composable internal fun AutoSizeTextField( textFieldValue: TextFieldValue, + focusRequester: FocusRequester, onAmountChange: (String) -> Unit, onFocusChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current - val textFieldFocusRequester = remember { FocusRequester() } + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { var shrunkFontSize = TangemTheme.typography.h2.fontSize val calculateIntrinsics = @Composable { @@ -71,7 +71,7 @@ internal fun AutoSizeTextField( singleLine = true, modifier = Modifier .fillMaxWidth() - .focusRequester(textFieldFocusRequester) + .focusRequester(focusRequester) .onFocusChanged { onFocusChange(it.hasFocus) }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Done, 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 index 2e04a4eef3..4015137fcd 100644 --- 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 @@ -19,7 +19,7 @@ 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.PercentLowerThanBest +import com.tangem.feature.swap.models.states.PercentDifference import com.tangem.feature.swap.models.states.ProviderState import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.toImmutableList @@ -114,7 +114,7 @@ private fun ChooseProviderBottomSheet_Preview() { iconUrl = "", subtitle = stringReference("1 000 000"), additionalBadge = ProviderState.AdditionalBadge.BestTrade, - percentLowerThenBest = PercentLowerThanBest.Value(-1.0f), + percentLowerThenBest = PercentDifference.Value(-1.0f), selectionType = ProviderState.SelectionType.SELECT, namePrefix = ProviderState.PrefixType.NONE, onProviderClick = {}, 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 index df12b50590..6ad6ced2fe 100644 --- 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 @@ -26,7 +26,7 @@ import com.tangem.core.ui.components.RectangleShimmer 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.PercentLowerThanBest +import com.tangem.feature.swap.models.states.PercentDifference import com.tangem.feature.swap.models.states.ProviderState /** @@ -94,11 +94,8 @@ private fun ProviderContentState( .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(), + model = ImageRequest.Builder(context = LocalContext.current).data(state.iconUrl) + .crossfade(enable = true).allowHardware(false).build(), loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) }, error = { ErrorProviderIcon( @@ -138,10 +135,12 @@ private fun ProviderContentState( ) } 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.BestTrade -> BestTradeItem( + Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem( + Modifier.padding(start = TangemTheme.dimens.spacing4), + ) ProviderState.AdditionalBadge.Empty -> { // no-op } @@ -162,14 +161,19 @@ private fun ProviderContentState( maxLines = 1, ) } - if (state.percentLowerThenBest is PercentLowerThanBest.Value && - state.percentLowerThenBest.value > 0 + if (state.percentLowerThenBest is PercentDifference.Value && + state.percentLowerThenBest.value != 0f ) { + val textColor = if (state.percentLowerThenBest.value > 0) { + TangemTheme.colors.icon.accent + } else { + TangemTheme.colors.text.warning + } AnimatedContent(targetState = state.percentLowerThenBest.value, label = "") { Text( - text = "-$it%", + text = if (it > 0) "+$it%" else "$it%", style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.warning, + color = textColor, modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), overflow = TextOverflow.Ellipsis, maxLines = 1, @@ -198,11 +202,8 @@ private fun ProviderUnavailableState( .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(), + model = ImageRequest.Builder(context = LocalContext.current).data(state.iconUrl) + .crossfade(enable = true).allowHardware(false).build(), loading = { RectangleShimmer(radius = TangemTheme.dimens.radius8) }, error = { ErrorProviderIcon( @@ -298,8 +299,7 @@ private fun ProviderLoadingState(modifier: Modifier = Modifier) { @Composable private fun BoxScope.ProviderChevron(selectionType: ProviderState.SelectionType, isSelected: Boolean) { when (selectionType) { - ProviderState.SelectionType.NONE -> { - /* no-op */ + ProviderState.SelectionType.NONE -> { /* no-op */ } ProviderState.SelectionType.CLICK -> { Icon( @@ -345,11 +345,10 @@ private fun BaseContainer(modifier: Modifier = Modifier, content: @Composable Bo @Composable private fun ErrorProviderIcon(modifier: Modifier = Modifier) { Box( - modifier = modifier - .background( - color = TangemTheme.colors.background.secondary, - shape = TangemTheme.shapes.roundedCorners8, - ), + modifier = modifier.background( + color = TangemTheme.colors.background.secondary, + shape = TangemTheme.shapes.roundedCorners8, + ), contentAlignment = Alignment.Center, ) { Icon( @@ -369,7 +368,7 @@ private fun BestTradeItem(modifier: Modifier = Modifier) { ), ) { Text( - text = "Best rate", + text = stringResource(R.string.express_provider_best_rate), style = TangemTheme.typography.caption1, color = TangemTheme.colors.icon.accent, modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6), @@ -432,7 +431,7 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider iconUrl = "", subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"), additionalBadge = ProviderState.AdditionalBadge.Empty, - percentLowerThenBest = PercentLowerThanBest.Empty, + percentLowerThenBest = PercentDifference.Value(value = 12.0f), selectionType = ProviderState.SelectionType.SELECT, namePrefix = ProviderState.PrefixType.PROVIDED_BY, onProviderClick = {}, @@ -440,7 +439,7 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider val contentState2 = contentState.copy( subtitle = stringReference(value = "1 132,46 MATIC"), additionalBadge = ProviderState.AdditionalBadge.PermissionRequired, - percentLowerThenBest = PercentLowerThanBest.Value(value = 5f), + percentLowerThenBest = PercentDifference.Value(value = 5f), ) val unavailableState = ProviderState.Unavailable( id = "1", 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 5e8c386764..cea2fca9cc 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 @@ -79,7 +79,6 @@ internal class StateBuilder( isBalanceHidden = true, ), fee = FeeItemState.Empty, - networkCurrency = networkInfo.blockchainCurrency, swapButton = SwapButton(enabled = false, onClick = {}), onRefresh = {}, onBackClicked = actions.onBackClicked, @@ -250,7 +249,6 @@ internal class StateBuilder( balance = toCurrencyStatus.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), - networkCurrency = quoteModel.networkCurrency, warnings = warnings, permissionState = convertPermissionState( lastPermissionState = uiStateHolder.permissionState, @@ -317,7 +315,7 @@ internal class StateBuilder( val warnings = mutableListOf() addDomainWarnings(quoteModel, warnings) if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && - quoteModel.preparedSwapConfigState.isFeeEnough && + quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough && quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest ) { warnings.add( @@ -412,7 +410,8 @@ internal class StateBuilder( fromToken: CryptoCurrency, warnings: MutableList, ) { - if (!quoteModel.preparedSwapConfigState.isFeeEnough && + val feeEnoughState = quoteModel.preparedSwapConfigState.feeState + if (feeEnoughState is SwapFeeState.NotEnough && quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading ) { @@ -420,7 +419,9 @@ internal class StateBuilder( SwapWarning.UnableToCoverFeeWarning( createUnableToCoverFeeNotificationConfig( fromToken = fromToken, - onBuyClick = actions.onBuyClick, + feeCurrency = feeEnoughState.feeCurrency, + currencyName = feeEnoughState.currencyName ?: fromToken.network.name, + currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol, ), ), ) @@ -438,7 +439,7 @@ internal class StateBuilder( IncludeFeeInAmount.Excluded -> preparedSwapConfigState.isAllowedToSpend && preparedSwapConfigState.isBalanceEnough && - preparedSwapConfigState.isFeeEnough + preparedSwapConfigState.feeState is SwapFeeState.Enough is IncludeFeeInAmount.Included -> true } } @@ -693,6 +694,32 @@ internal class StateBuilder( ) } + fun updateSendCurrencyBalance( + uiState: SwapStateHolder, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): SwapStateHolder { + if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState + + return uiState.copy( + sendCardData = uiState.sendCardData.copy( + balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), + ), + ) + } + + fun updateReceiveCurrencyBalance( + uiState: SwapStateHolder, + cryptoCurrencyStatus: CryptoCurrencyStatus, + ): SwapStateHolder { + if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState + + return uiState.copy( + receiveCardData = uiState.receiveCardData.copy( + balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), + ), + ) + } + fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState @@ -998,6 +1025,7 @@ internal class StateBuilder( fun showSelectProviderBottomSheet( uiState: SwapStateHolder, selectedProviderId: String, + bestRatedProviderId: String, pricesLowerBest: Map, providersStates: Map, unavailableProviders: List, @@ -1005,7 +1033,7 @@ internal class StateBuilder( ): SwapStateHolder { val availableProvidersStates = providersStates.entries .mapNotNull { - it.convertToProviderBottomSheetState(pricesLowerBest, actions.onProviderSelect) + it.convertToProviderBottomSheetState(pricesLowerBest, bestRatedProviderId, actions.onProviderSelect) } .sortedWith(ProviderPercentDiffComparator) val unavailableProviderStates = unavailableProviders.map { @@ -1046,8 +1074,8 @@ internal class StateBuilder( it.copy( subtitle = stringReference(rateString), percentLowerThenBest = pricesLowerBest[it.id]?.let { percent -> - PercentLowerThanBest.Value(percent) - } ?: PercentLowerThanBest.Empty, + PercentDifference.Value(percent) + } ?: PercentDifference.Value(0f), ) } else { it @@ -1093,7 +1121,7 @@ internal class StateBuilder( }, readMoreUrl = buildReadMoreUrl(), feeItems = txFeeState.toFeeItemState(), - readMore = resourceReference(R.string.common_fee_selector_link_description), + readMore = resourceReference(R.string.common_read_more), onReadMoreClick = actions.onFeeReadMoreClick, ) return uiState.copy( @@ -1153,18 +1181,21 @@ internal class StateBuilder( private fun Map.Entry.convertToProviderBottomSheetState( pricesLowerBest: Map, + bestRatedProviderId: String, 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.QuotesLoadedState -> { + provider.convertToContentSelectableProviderState( + isBestRate = bestRatedProviderId == provider.providerId, + state = state, + onProviderClick = onProviderSelect, + pricesLowerBest = pricesLowerBest, + selectionType = ProviderState.SelectionType.SELECT, + ) + } is SwapState.SwapError -> getProviderStateForError( swapProvider = provider, fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency, @@ -1189,8 +1220,16 @@ internal class StateBuilder( private fun createUnableToCoverFeeNotificationConfig( fromToken: CryptoCurrency, - onBuyClick: () -> Unit, + feeCurrency: CryptoCurrency?, + currencyName: String, + currencySymbol: String, ): NotificationConfig { + val buttonState = feeCurrency?.let { + NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)), + onClick = { actions.onBuyClick(it) }, + ) + } return NotificationConfig( title = resourceReference( R.string.warning_express_not_enough_fee_for_token_tx_title, @@ -1198,20 +1237,17 @@ internal class StateBuilder( ), subtitle = resourceReference( R.string.warning_express_not_enough_fee_for_token_tx_description, - wrappedList(fromToken.network.name, fromToken.network.currencySymbol), + wrappedList(currencyName, currencySymbol), ), iconResId = fromToken.networkIconResId, - buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( - text = resourceReference(R.string.common_buy_currency, wrappedList(fromToken.network.currencySymbol)), - onClick = onBuyClick, - ), + buttonsState = buttonState, ) } private fun createNetworkFeeCoverageNotificationConfig(): NotificationConfig { return NotificationConfig( title = resourceReference(R.string.send_network_fee_warning_title), - subtitle = resourceReference(R.string.send_network_fee_warning_content), + subtitle = resourceReference(R.string.swapping_network_fee_warning_content), iconResId = R.drawable.img_attention_20, ) } @@ -1256,7 +1292,7 @@ internal class StateBuilder( subtitle = stringReference(rateString), additionalBadge = badge, selectionType = selectionType, - percentLowerThenBest = PercentLowerThanBest.Empty, + percentLowerThenBest = PercentDifference.Empty, namePrefix = ProviderState.PrefixType.PROVIDED_BY, onProviderClick = onProviderClick, ) @@ -1287,8 +1323,8 @@ internal class StateBuilder( additionalBadge = additionalBadge, selectionType = selectionType, percentLowerThenBest = pricesLowerBest[this.providerId]?.let { percent -> - PercentLowerThanBest.Value(percent) - } ?: PercentLowerThanBest.Value(0f), + PercentDifference.Value(percent) + } ?: PercentDifference.Value(0f), namePrefix = ProviderState.PrefixType.NONE, onProviderClick = onProviderClick, ) @@ -1323,7 +1359,7 @@ internal class StateBuilder( selectionType = selectionType, subtitle = alertText, additionalBadge = ProviderState.AdditionalBadge.Empty, - percentLowerThenBest = PercentLowerThanBest.Empty, + percentLowerThenBest = PercentDifference.Empty, namePrefix = if (selectionType != ProviderState.SelectionType.SELECT) { ProviderState.PrefixType.PROVIDED_BY } else { 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 4f2a3fefa1..6c31dea226 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 @@ -490,7 +490,6 @@ private val state = SwapStateHolder( ), ), ), - networkCurrency = "MATIC", swapButton = SwapButton(enabled = true, onClick = {}), onRefresh = {}, onBackClicked = {}, 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 9e7a5f8b2d..d9a604c6d4 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 @@ -19,6 +19,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext @@ -262,12 +263,19 @@ private fun Content( } } is TransactionCardType.Inputtable -> { + val focusRequester = remember { FocusRequester() } + AutoSizeTextField( modifier = sumTextModifier, + focusRequester = focusRequester, textFieldValue = textFieldValue ?: TextFieldValue(), onAmountChange = { type.onAmountChanged(it) }, onFocusChange = type.onFocusChanged, ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } } } 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 39ca0d7991..a559fb5054 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,6 +4,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.* +import arrow.core.Either import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -14,11 +15,13 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.tokens.GetCurrencyStatusUpdatesUseCase import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase 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.domain.wallets.models.UserWalletId import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.domain.BlockchainInteractor import com.tangem.feature.swap.domain.SwapInteractor @@ -47,7 +50,6 @@ 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 @@ -63,6 +65,7 @@ internal class SwapViewModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase, private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, + private val getCurrencyStatusUpdatesUseCase: GetCurrencyStatusUpdatesUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel(), DefaultLifecycleObserver { @@ -105,6 +108,9 @@ internal class SwapViewModel @Inject constructor( (it.error is DataError.ExchangeTooSmallAmountError || it.error is DataError.ExchangeTooBigAmountError) } + private val fromTokenBalanceJobHolder = JobHolder() + private val toTokenBalanceJobHolder = JobHolder() + val currentScreen: SwapNavScreen get() = swapRouter.currentScreen @@ -174,6 +180,23 @@ internal class SwapViewModel @Inject constructor( state, ), ) + + val userWalletId = swapInteractor.getSelectedWallet()?.walletId ?: return@launch + (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = it, + isFromCurrency = true, + ) + } + + (dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = it, + isFromCurrency = false, + ) + } }.onFailure { Timber.tag(loggingTag).e(it) @@ -302,7 +325,7 @@ internal class SwapViewModel @Inject constructor( val (provider, state) = updateLoadedQuotes(providersState) setupLoadedState(provider, state, fromToken) val successStates = providersState.getLastLoadedSuccessStates() - val pricesLowerBest = getPricesLowerBest(successStates) + val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates) uiState = stateBuilder.updateProvidersBottomSheetContent( uiState = uiState, pricesLowerBest = pricesLowerBest, @@ -623,7 +646,6 @@ internal class SwapViewModel @Inject constructor( unavailable = unavailable, afterSearch = true, ), - ) } else { tokenDataState.copy( @@ -653,15 +675,35 @@ internal class SwapViewModel @Inject constructor( analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it)) } + val userWalletId = swapInteractor.getSelectedWallet()?.walletId + if (foundToken != null) { val fromToken: CryptoCurrencyStatus val toToken: CryptoCurrencyStatus if (isOrderReversed) { fromToken = foundToken.currencyStatus toToken = initialCryptoCurrencyStatus + + val newToken = fromToken.currency as? CryptoCurrency.Coin + if (userWalletId != null && newToken != null) { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = newToken, + isFromCurrency = true, + ) + } } else { fromToken = initialCryptoCurrencyStatus toToken = foundToken.currencyStatus + + val newToken = toToken.currency as? CryptoCurrency.Coin + if (userWalletId != null && newToken != null) { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = newToken, + isFromCurrency = false, + ) + } } dataState = dataState.copy( fromCryptoCurrency = fromToken, @@ -679,6 +721,36 @@ internal class SwapViewModel @Inject constructor( } } + private fun subscribeToCoinBalanceUpdates( + userWalletId: UserWalletId, + coin: CryptoCurrency.Coin, + isFromCurrency: Boolean, + ) { + Timber.d("Subscribe to ${coin.id} balance updates") + + getCurrencyStatusUpdatesUseCase( + userWalletId = userWalletId, + currencyId = coin.id, + isSingleWalletWithTokens = false, + ) + .mapNotNull { (it as? Either.Right)?.value } + .distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes + .onEach { + Timber.d("${coin.id} balance is ${it.value.amount}") + + uiState = if (isFromCurrency) { + dataState = dataState.copy(fromCryptoCurrency = it) + stateBuilder.updateSendCurrencyBalance(uiState, it) + } else { + dataState = dataState.copy(toCryptoCurrency = it) + stateBuilder.updateReceiveCurrencyBalance(uiState, it) + } + } + .flowOn(dispatchers.main) + .launchIn(viewModelScope) + .saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder) + } + private fun onChangeCardsClicked() { val newFromToken = dataState.toCryptoCurrency val newToToken = dataState.fromCryptoCurrency @@ -847,13 +919,14 @@ internal class SwapViewModel @Inject constructor( onProviderClick = { providerId -> analyticsEventHandler.send(SwapEvents.ProviderClicked) val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() - val pricesLowerBest = getPricesLowerBest(states) + val pricesLowerBest = getPricesLowerBest(providerId, states) val unavailableProviders = getUnavailableProvidersFor(dataState.lastLoadedSwapStates) uiState = stateBuilder.showSelectProviderBottomSheet( uiState = uiState, selectedProviderId = providerId, pricesLowerBest = pricesLowerBest, unavailableProviders = unavailableProviders, + bestRatedProviderId = findBestQuoteProvider(states)?.providerId ?: providerId, providersStates = dataState.lastLoadedSwapStates, ) { uiState = stateBuilder.dismissBottomSheet(uiState) } }, @@ -871,12 +944,11 @@ internal class SwapViewModel @Inject constructor( ) } }, - onBuyClick = { - swapInteractor.getSelectedWallet()?.let { - val fromToken = dataState.fromCryptoCurrency ?: return@let + onBuyClick = { currency -> + swapInteractor.getSelectedWallet()?.let { userWallet -> swapRouter.openTokenDetails( - it.walletId, - swapInteractor.getNativeToken(fromToken.currency.network.backendId), + userWalletId = userWallet.walletId, + currency = currency, ) } }, @@ -962,17 +1034,18 @@ internal class SwapViewModel @Inject constructor( }?.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 + private fun getPricesLowerBest(selectedProviderId: String, state: SuccessLoadedSwapData): Map { + val selectedProviderEntry = state.filter { it.key.providerId == selectedProviderId }.entries.firstOrNull() + ?: return emptyMap() + val selectedProviderRate = selectedProviderEntry.value.toTokenInfo.tokenAmount.value val hundredPercent = BigDecimal("100") return state.entries.mapNotNull { - if (it.key != bestRateEntry.key) { + if (it.key != selectedProviderEntry.key) { val amount = it.value.toTokenInfo.tokenAmount.value val percentDiff = BigDecimal.ONE.minus( - amount.divide(bestRate, RoundingMode.HALF_UP), + selectedProviderRate.divide(amount, RoundingMode.HALF_UP), ).multiply(hundredPercent) - it.key.providerId to percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat().absoluteValue + it.key.providerId to percentDiff.setScale(2, RoundingMode.HALF_UP).toFloat() } else { null } diff --git a/features/tester/api/build.gradle.kts b/features/tester/api/build.gradle.kts index 1fbece85d5..0456459cad 100644 --- a/features/tester/api/build.gradle.kts +++ b/features/tester/api/build.gradle.kts @@ -2,4 +2,8 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) id("configuration") +} + +android { + namespace = "com.tangem.features.tester.api" } \ No newline at end of file diff --git a/features/tester/api/src/main/AndroidManifest.xml b/features/tester/api/src/main/AndroidManifest.xml deleted file mode 100644 index 99ac562598..0000000000 --- a/features/tester/api/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 9bc0022b59..86db03dca7 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.feature.tester.impl" +} + dependencies { /** AndroidX */ implementation(deps.androidx.activity.compose) diff --git a/features/tester/impl/src/main/AndroidManifest.xml b/features/tester/impl/src/main/AndroidManifest.xml index b4e4fc0cfd..b729d8f73c 100644 --- a/features/tester/impl/src/main/AndroidManifest.xml +++ b/features/tester/impl/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + Unit, ) : Warning( title = TextReference.Res( @@ -120,7 +121,13 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) { NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference( id = R.string.common_buy_currency, - formatArgs = wrappedList(feeCurrencySymbol), + formatArgs = wrappedList( + if (mergeFeeNetworkName) { + "$feeCurrencyName ($feeCurrencySymbol)" + } else { + feeCurrencySymbol + }, + ), ), onClick = onBuyClick, ), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt index e8c09263eb..5048215ac6 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsLoadedBalanceConverter.kt @@ -4,6 +4,7 @@ import arrow.core.Either 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.components.marketprice.utils.PriceChangeConverter import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency @@ -18,7 +19,6 @@ import com.tangem.utils.Provider import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import java.math.BigDecimal internal class TokenDetailsLoadedBalanceConverter( private val currentStateProvider: Provider, @@ -118,9 +118,7 @@ internal class TokenDetailsLoadedBalanceConverter( } private fun getPriceChangeType(status: CryptoCurrencyStatus.Status): PriceChangeType { - val priceChange = status.priceChange ?: return PriceChangeType.DOWN - - return if (priceChange > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN + return PriceChangeConverter.fromBigDecimal(status.priceChange) } private fun formatPriceChange(status: CryptoCurrencyStatus.Status): String { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index bf37784e25..eaeb7f760a 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt @@ -1,5 +1,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory +import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.common.extensions.fromNetworkId +import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification @@ -28,9 +31,10 @@ internal class TokenDetailsNotificationConverter( return when (warning) { is CryptoCurrencyWarning.BalanceNotEnoughForFee -> NetworkFeeWithBuyButton( currency = warning.tokenCurrency, - networkName = warning.coinCurrency.name, + networkName = warning.coinCurrency.network.name, feeCurrencyName = warning.coinCurrency.name, feeCurrencySymbol = warning.coinCurrency.symbol, + mergeFeeNetworkName = warning.coinCurrency.shouldMergeFeeNetworkName(), onBuyClick = { clickIntents.onBuyCoinClick(warning.coinCurrency) }, ) is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> { @@ -41,6 +45,7 @@ internal class TokenDetailsNotificationConverter( networkName = feeCurrency.network.name, feeCurrencyName = warning.feeCurrencyName, feeCurrencySymbol = warning.feeCurrencySymbol, + mergeFeeNetworkName = warning.currency.shouldMergeFeeNetworkName(), onBuyClick = { clickIntents.onBuyCoinClick(feeCurrency) }, ) } else { @@ -75,4 +80,9 @@ internal class TokenDetailsNotificationConverter( ) } } + + // workaround for networks that users have misunderstanding + private fun CryptoCurrency.shouldMergeFeeNetworkName(): Boolean { + return Blockchain.fromNetworkId(this.network.backendId) == Blockchain.Arbitrum + } } \ No newline at end of file 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 index fe66191de0..d5445e3d1a 100644 --- 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 @@ -47,18 +47,18 @@ internal class TokenDetailsSwapTransactionsStateConverter( val result = mutableListOf() savedTransactions - .forEach { swapCurrency -> - val toCryptoCurrency = swapCurrency.toCryptoCurrency - val fromCryptoCurrency = swapCurrency.fromCryptoCurrency + .forEach { swapTransaction -> + val toCryptoCurrency = swapTransaction.toCryptoCurrency + val fromCryptoCurrency = swapTransaction.fromCryptoCurrency - swapCurrency.transactions.forEach { transaction -> + swapTransaction.transactions.forEach { transaction -> val toAmount = transaction.toCryptoAmount val fromAmount = transaction.fromCryptoAmount val toFiatAmount = quotes.firstOrNull { - it.rawCurrencyId == swapCurrency.toCryptoCurrency.id.rawCurrencyId + it.rawCurrencyId == swapTransaction.toCryptoCurrency.id.rawCurrencyId }?.fiatRate?.multiply(toAmount) val fromFiatAmount = quotes.firstOrNull { - it.rawCurrencyId == swapCurrency.fromCryptoCurrency.id.rawCurrencyId + it.rawCurrencyId == swapTransaction.fromCryptoCurrency.id.rawCurrencyId }?.fiatRate?.multiply(fromAmount) val timestamp = transaction.timestamp val notifications = @@ -69,6 +69,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( txId = transaction.txId, provider = transaction.provider, txUrl = transaction.status?.txExternalUrl, + txExternalId = transaction.status?.txExternalId, timestamp = TextReference.Str( "${timestamp.toDateFormat()}, ${timestamp.toTimeFormat()}", ), 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 index 26c7393a85..c08898d2dc 100644 --- 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 @@ -1,36 +1,79 @@ 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.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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +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.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview 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) { +internal fun ExchangeProvider( + providerName: TextReference, + providerType: TextReference, + providerTxId: String?, + imageUrl: String, +) { + val clipboardManager = LocalClipboardManager.current + val hapticFeedback = LocalHapticFeedback.current 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, + Box( modifier = Modifier - .padding( - start = TangemTheme.dimens.spacing12, - end = TangemTheme.dimens.spacing12, - top = TangemTheme.dimens.spacing12, - ), - ) + .padding(top = TangemTheme.dimens.spacing12) + .padding(horizontal = TangemTheme.dimens.spacing12) + .fillMaxWidth(), + ) { + Text( + text = stringResource(id = R.string.express_provider), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.align(Alignment.CenterStart), + ) + if (!providerTxId.isNullOrEmpty()) { + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + clipboardManager.setText(AnnotatedString(providerTxId)) + }, + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size20) + .padding(end = TangemTheme.dimens.spacing4) + .align(Alignment.CenterVertically), + painter = painterResource(id = R.drawable.ic_copy_24), + contentDescription = null, + tint = TangemTheme.colors.text.tertiary, + ) + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = "ID: $providerTxId", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } InputRowBestRate( imageUrl = imageUrl, title = providerName, @@ -38,4 +81,17 @@ internal fun ExchangeProvider(providerName: TextReference, providerType: TextRef subtitle = TextReference.Res(R.string.express_floating_rate), ) } +} + +@Preview(showBackground = true) +@Composable +private fun ExchangeProvider_Preview() { + TangemTheme(isDark = false) { + ExchangeProvider( + providerName = TextReference.Str("Changelly"), + providerType = TextReference.Str("CEX"), + providerTxId = "hjsbajcqb", + imageUrl = "", + ) + } } \ 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 index 374c1d4909..1df5e537c1 100644 --- 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 @@ -71,6 +71,7 @@ private fun ExchangeStatusBottomSheetContent(content: ExchangeStatusBottomSheetC ExchangeProvider( providerName = TextReference.Str(config.provider.name), providerType = TextReference.Str(config.provider.type.name), + providerTxId = config.txExternalId, imageUrl = config.provider.imageLarge, ) SpacerH12() 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 382d9ca76e..fbdbcf8273 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 @@ -43,6 +43,7 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender @@ -84,6 +85,7 @@ internal class TokenDetailsViewModel @Inject constructor( private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val shouldShowSwapPromoTokenUseCase: ShouldShowSwapPromoTokenUseCase, + private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, private val swapRepository: SwapRepository, private val swapTransactionRepository: SwapTransactionRepository, private val quotesRepository: QuotesRepository, @@ -301,6 +303,9 @@ internal class TokenDetailsViewModel @Inject constructor( val config = uiState.bottomSheetConfig val exchangeBottomSheet = config?.content as? ExchangeStatusBottomSheetConfig val currentTx = swapTxs.firstOrNull { it.txId == exchangeBottomSheet?.value?.txId } + if (currentTx?.activeStatus == ExchangeStatus.Finished) { + updateNetworkToSwapBalance(currentTx.toCryptoCurrency) + } uiState = uiState.copy( swapTxs = swapTxs, bottomSheetConfig = currentTx?.let( @@ -309,6 +314,16 @@ internal class TokenDetailsViewModel @Inject constructor( ) } + private fun updateNetworkToSwapBalance(toCryptoCurrency: CryptoCurrency) { + viewModelScope.launch { + updateDelayedCurrencyStatusUseCase( + userWalletId = userWalletId, + network = toCryptoCurrency.network, + refresh = true, + ) + } + } + /** * @param refresh - invalidate cache and get data from remote * @param showItemsLoading - show loading items placeholder. diff --git a/features/wallet/api/build.gradle.kts b/features/wallet/api/build.gradle.kts index 3b5deb5bae..2e8f294e46 100644 --- a/features/wallet/api/build.gradle.kts +++ b/features/wallet/api/build.gradle.kts @@ -4,6 +4,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.features.wallet.api" +} + dependencies { /** AndroidX */ implementation(deps.androidx.fragment.ktx) diff --git a/features/wallet/api/src/main/AndroidManifest.xml b/features/wallet/api/src/main/AndroidManifest.xml deleted file mode 100644 index 85a7d6c7c5..0000000000 --- a/features/wallet/api/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index e2d5cb6d0f..db4d102187 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.feature.wallet.impl" +} + dependencies { /** AndroidX */ implementation(deps.androidx.activity.compose) diff --git a/features/wallet/impl/src/main/AndroidManifest.xml b/features/wallet/impl/src/main/AndroidManifest.xml deleted file mode 100644 index a1a96bfa00..0000000000 --- a/features/wallet/impl/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenPrice.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenPrice.kt index 06cd93ccec..4822e0d489 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenPrice.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/token/TokenPrice.kt @@ -87,11 +87,13 @@ private fun PriceChangeIcon(type: PriceChangeType) { id = when (animatedType) { PriceChangeType.UP -> R.drawable.ic_arrow_up_8 PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8 + PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8 }, ), tint = when (animatedType) { PriceChangeType.UP -> TangemTheme.colors.icon.accent PriceChangeType.DOWN -> TangemTheme.colors.icon.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors.icon.inactive }, contentDescription = null, ) @@ -106,6 +108,7 @@ private fun PriceChangeText(type: PriceChangeType?, text: String?, modifier: Mod color = when (type) { PriceChangeType.UP -> TangemTheme.colors.text.accent PriceChangeType.DOWN -> TangemTheme.colors.text.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors.text.disabled null -> TangemTheme.colors.text.tertiary }, overflow = TextOverflow.Ellipsis, 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 54ca84fe4d..5ae3d34992 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 @@ -28,6 +28,7 @@ import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensScree import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensViewModel import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen import com.tangem.feature.wallet.presentation.wallet.viewmodels.WalletViewModel +import com.tangem.features.managetokens.navigation.ExpandableState import com.tangem.features.managetokens.navigation.ManageTokensUi import com.tangem.features.tokendetails.navigation.TokenDetailsRouter import kotlin.properties.Delegates @@ -62,10 +63,13 @@ internal class DefaultWalletRouter( state = viewModel.uiState.collectAsStateWithLifecycle().value, bottomSheetHeaderHeightProvider = { bottomSheetHeaderHeight }, bottomSheetContent = { + val state = remember { mutableStateOf(ExpandableState.COLLAPSED) } // Manage Tokens manageTokensUi.Content( onHeaderSizeChange = { bottomSheetHeaderHeight = it }, + state = state, ) + viewModel.setExpandableState(state) }, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 9cfd2fe858..a8b311b067 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -58,5 +58,6 @@ internal interface InnerWalletRouter : WalletRouter { /** Open manage tokens screen */ fun openManageTokensScreen() + /** Open scan failed dialog */ fun openScanFailedDialog() } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt index f9e1705b63..e8c3c9114c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/WalletStateController.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state +import androidx.compose.runtime.mutableStateOf import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.event.consumedEvent import com.tangem.domain.wallets.models.UserWalletId @@ -11,6 +12,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBot import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.WalletScreenStateTransformer import com.tangem.features.managetokens.featuretoggles.ManageTokensFeatureToggles +import com.tangem.features.managetokens.navigation.ExpandableState import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -86,6 +88,7 @@ internal class WalletStateController @Inject constructor( event = consumedEvent(), isHidingMode = false, manageTokenRedesignToggle = manageTokensFeatureToggles.isRedesignedScreenEnabled, + manageTokensExpandableState = mutableStateOf(ExpandableState.EXPANDED), ) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt index b4a5a8dc25..5c9424f9ee 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt @@ -1,10 +1,13 @@ package com.tangem.feature.wallet.presentation.wallet.state.model +import androidx.compose.runtime.MutableState import com.tangem.core.ui.event.StateEvent +import com.tangem.features.managetokens.navigation.ExpandableState import kotlinx.collections.immutable.ImmutableList internal data class WalletScreenState( val onBackClick: () -> Unit, + val manageTokensExpandableState: MutableState, val topBarConfig: WalletTopBarConfig, val selectedWalletIndex: Int, val wallets: ImmutableList, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletMarketPriceConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletMarketPriceConverter.kt index 60fcaa1c3c..157d0a1180 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletMarketPriceConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletMarketPriceConverter.kt @@ -3,11 +3,11 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert 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.components.marketprice.utils.PriceChangeConverter 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, @@ -61,8 +61,6 @@ internal class SingleWalletMarketPriceConverter( } private fun getPriceChangeType(status: CryptoCurrencyStatus.Status): PriceChangeType { - val priceChange = status.priceChange ?: return PriceChangeType.DOWN - - return if (priceChange > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN + return PriceChangeConverter.fromBigDecimal(status.priceChange) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt index 77aa101de4..aa168db655 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenItemStateConverter.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.model.CryptoCurrencyStatus @@ -97,8 +98,6 @@ internal class TokenItemStateConverter( priceChangePercent = BigDecimalFormatter.formatPercent( percent = priceChange, useAbsoluteValue = true, - maxFractionDigits = 1, - minFractionDigits = 1, ), type = priceChange.getPriceChangeType(), ) @@ -117,6 +116,6 @@ internal class TokenItemStateConverter( } private fun BigDecimal.getPriceChangeType(): PriceChangeType { - return if (this > BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN + return PriceChangeConverter.fromBigDecimal(value = this) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 8341a0792c..9d6dc72199 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -1,7 +1,11 @@ package com.tangem.feature.wallet.presentation.wallet.ui import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.TweenSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -13,16 +17,19 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.paging.compose.collectAsLazyPagingItems import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.tangem.core.ui.components.Keyboard import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SystemBarsEffect import com.tangem.core.ui.components.atoms.Hand import com.tangem.core.ui.components.atoms.handComposableComponentHeight import com.tangem.core.ui.components.bottomsheets.chooseaddress.ChooseAddressBottomSheet @@ -46,6 +53,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.VisaTxDe import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.balancesAndLimitsBlock import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.depositButton import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator +import com.tangem.features.managetokens.navigation.ExpandableState import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @@ -64,6 +72,13 @@ internal fun WalletScreen( val snackbarHostState = remember(::SnackbarHostState) val isAutoScroll = remember { mutableStateOf(value = false) } + var alertConfig by remember { mutableStateOf(value = null) } + + val config = alertConfig + if (config != null) { + WalletAlert(state = config, onDismiss = { alertConfig = null }) + } + WalletContent( state = state, walletsListState = walletsListState, @@ -72,14 +87,9 @@ internal fun WalletScreen( onAutoScrollReset = { isAutoScroll.value = false }, bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider, bottomSheetContent = bottomSheetContent, + alertConfig = alertConfig, ) - var alertConfig by remember { mutableStateOf(value = null) } - - alertConfig?.let { - WalletAlert(state = it, onDismiss = { alertConfig = null }) - } - WalletEventEffect( event = state.event, selectedWalletIndex = state.selectedWalletIndex, @@ -100,8 +110,9 @@ private fun WalletContent( bottomSheetHeaderHeightProvider: () -> Dp, onAutoScrollReset: () -> Unit, bottomSheetContent: @Composable () -> Unit, + alertConfig: WalletAlertState?, ) { - var selectedWalletIndex by remember { mutableIntStateOf(state.selectedWalletIndex) } + var selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) } val selectedWallet = state.wallets[selectedWalletIndex] val scaffoldContent: @Composable () -> Unit = { @@ -207,6 +218,7 @@ private fun WalletContent( snackbarHostState = snackbarHostState, bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider, bottomSheetContent = bottomSheetContent, + alertConfig = alertConfig, ) { scaffoldContent() } @@ -222,7 +234,7 @@ private fun WalletContent( } @Suppress("LongParameterList", "LongMethod") -@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable private fun BaseScaffoldManageTokenRedesign( state: WalletScreenState, @@ -230,49 +242,47 @@ private fun BaseScaffoldManageTokenRedesign( snackbarHostState: SnackbarHostState, bottomSheetHeaderHeightProvider: () -> Dp, bottomSheetContent: @Composable () -> Unit, + alertConfig: WalletAlertState?, content: @Composable () -> Unit, ) { - val scaffoldState = rememberBottomSheetScaffoldState() + // show the bottom sheet if there is at least one multicurrency wallet + val showManageTokensBottomSheet = remember(state.wallets) { + state.wallets.any { it is WalletState.MultiCurrency } + } + val bottomSheetState = rememberSheetStateEnhanced( + initialValue = if (showManageTokensBottomSheet) SheetValue.PartiallyExpanded else SheetValue.Hidden, + confirmValueChange = { sheetValue -> + when { + sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false + sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false + else -> true + } + }, + skipHiddenState = showManageTokensBottomSheet, + ) + + val keyboardShown = keyboardAsState() + + BottomSheetStateEffects( + bottomSheetState = bottomSheetState, + state = state, + showManageTokensBottomSheet = showManageTokensBottomSheet, + alertConfig = alertConfig, + keyboardShown = keyboardShown, + ) + + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = bottomSheetState, + snackbarHostState = snackbarHostState, + ) + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } - val systemUiController = rememberSystemUiController() - val navigationBarColor = TangemTheme.colors.background.primary - val navigationBarColorWithout = TangemTheme.colors.background.secondary - - DisposableEffect( - navigationBarColor, - navigationBarColorWithout, - ) { - systemUiController.setNavigationBarColor(navigationBarColor) - onDispose { - systemUiController.setNavigationBarColor(navigationBarColorWithout) - } - } - - val keyboardShown by keyboardAsState() - // expand bottom sheet when keyboard appears - LaunchedEffect(keyboardShown is Keyboard.Opened) { - if (keyboardShown is Keyboard.Opened) { - scaffoldState.bottomSheetState.expand() - } - } - - val keyboardController = LocalSoftwareKeyboardController.current - val sheetHasBeenHidden = scaffoldState.bottomSheetState.targetValue == SheetValue.PartiallyExpanded - // hide keyboard when bottom sheet is about to be hidden - LaunchedEffect(sheetHasBeenHidden) { - if (sheetHasBeenHidden) { - keyboardController?.hide() - } - } - val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight + val coroutineScope = rememberCoroutineScope() BottomSheetScaffold( - topBar = { - WalletTopBar(config = state.topBarConfig) - }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, @@ -296,10 +306,10 @@ private fun BaseScaffoldManageTokenRedesign( // hide bottom sheet when back pressed BackHandler( - keyboardShown is Keyboard.Closed && - scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded, + keyboardShown.value is Keyboard.Closed && + bottomSheetState.currentValue == SheetValue.Expanded, ) { - coroutineScope.launch { scaffoldState.bottomSheetState.partialExpand() } + coroutineScope.launch { bottomSheetState.partialExpand() } } }, content = { paddingValues -> @@ -308,23 +318,164 @@ private fun BaseScaffoldManageTokenRedesign( onRefresh = selectedWallet.pullToRefreshConfig.onRefresh, ) - Box( - modifier = Modifier - .pullRefresh(pullRefreshState) - .padding(paddingValues), + Column( + modifier = Modifier.padding(paddingValues), ) { - content() + WalletTopBar(config = state.topBarConfig) + Box( + modifier = Modifier.pullRefresh(pullRefreshState), + ) { + content() - WalletPullToRefreshIndicator( - isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter), - ) + WalletPullToRefreshIndicator( + isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } } + + BottomSheetScrim( + color = BottomSheetDefaults.ScrimColor, + visible = bottomSheetState.targetValue == SheetValue.Expanded, + onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, + ) }, ) } +@Composable +private fun BottomSheetScrim(color: Color, visible: Boolean, onDismissRequest: () -> Unit) { + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = TweenSpec(), + label = "scrim", + ) + val dismissSheet = if (visible) { + Modifier + .pointerInput(onDismissRequest) { + detectTapGestures { + onDismissRequest() + } + } + .clearAndSetSemantics {} + } else { + Modifier + } + Canvas( + Modifier + .fillMaxSize() + .then(dismissSheet), + ) { + drawRect(color = color, alpha = alpha) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("CyclomaticComplexMethod") +@Composable +private fun BottomSheetStateEffects( + bottomSheetState: SheetState, + state: WalletScreenState, + showManageTokensBottomSheet: Boolean, + alertConfig: WalletAlertState?, + keyboardShown: State, +) { + // Bottom sheet during initialization internally expand partially after its content was remeasured, + // therefore initialValue = SheetValue.Hidden in rememberStandardBottomSheetState doesn't work as expected + // so we have to manually restrict expansion in this case + LaunchedEffect(bottomSheetState.targetValue, bottomSheetState.currentValue) { + if (!showManageTokensBottomSheet && + (bottomSheetState.targetValue != SheetValue.Hidden || bottomSheetState.currentValue != SheetValue.Hidden) + ) { + bottomSheetState.hide() + } + } + // react to changes in wallet list + LaunchedEffect(showManageTokensBottomSheet) { + when { + showManageTokensBottomSheet && bottomSheetState.currentValue != SheetValue.PartiallyExpanded -> { + bottomSheetState.partialExpand() + } + !showManageTokensBottomSheet && bottomSheetState.targetValue != SheetValue.Hidden -> { + bottomSheetState.hide() + } + } + } + + val systemUiController = rememberSystemUiController() + val navigationBarColor = TangemTheme.colors.background.primary + val navigationBarColorWithout = TangemTheme.colors.background.secondary + + SystemBarsEffect { + if (showManageTokensBottomSheet) { + setNavigationBarColor(navigationBarColor) + } + } + DisposableEffect( + showManageTokensBottomSheet, + ) { + onDispose { + if (showManageTokensBottomSheet) { + systemUiController.setNavigationBarColor(navigationBarColorWithout) + } + } + } + + // expand bottom sheet when keyboard appears + LaunchedEffect(keyboardShown.value is Keyboard.Opened) { + if (keyboardShown.value is Keyboard.Opened && alertConfig == null) { + bottomSheetState.expand() + } + } + + val keyboardController = LocalSoftwareKeyboardController.current + // hide keyboard when bottom sheet is about to be hidden + LaunchedEffect(Unit) { + snapshotFlow { + bottomSheetState.currentValue == SheetValue.Expanded && + bottomSheetState.targetValue == SheetValue.PartiallyExpanded + }.collect { sheetHasBeenHidden -> + if (sheetHasBeenHidden) { + keyboardController?.hide() + } + } + } + + val isSheetHidden = bottomSheetState.targetValue == SheetValue.PartiallyExpanded + LaunchedEffect(isSheetHidden) { + if (isSheetHidden) { + state.manageTokensExpandableState.value = ExpandableState.COLLAPSED + } else { + state.manageTokensExpandableState.value = ExpandableState.EXPANDED + } + } +} + +/** + * Use a standard method when this is fixed https://issuetracker.google.com/issues/314796718 + * Current material3 version: 1.2.0 + */ +@Composable +@ExperimentalMaterial3Api +private fun rememberSheetStateEnhanced( + skipPartiallyExpanded: Boolean = false, + confirmValueChange: (SheetValue) -> Boolean = { true }, + initialValue: SheetValue = SheetValue.Hidden, + skipHiddenState: Boolean = false, +): SheetState { + val density = LocalDensity.current + return remember(initialValue, skipPartiallyExpanded, confirmValueChange, skipHiddenState) { + SheetState( + skipPartiallyExpanded = skipPartiallyExpanded, + density = density, + initialValue = initialValue, + confirmValueChange = confirmValueChange, + skipHiddenState = skipHiddenState, + ) + } +} + @OptIn(ExperimentalMaterialApi::class) @Composable private fun BaseScaffold( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffects.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffects.kt index 5765897931..d2173369f4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffects.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletsListEffects.kt @@ -5,6 +5,7 @@ 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.LazyListItemData import com.tangem.feature.wallet.presentation.wallet.ui.utils.ScrollOffsetCollector import com.tangem.feature.wallet.presentation.wallet.ui.utils.WalletsListInteractionsCollector @@ -19,7 +20,11 @@ internal fun WalletsListEffects( onAutoScrollReset: () -> Unit, ) { LaunchedEffect(key1 = lazyListState, key2 = onWalletChange) { - snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo } + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.map { + LazyListItemData(it.index, it.size, it.offset) + } + } .collect( collector = ScrollOffsetCollector( selectedWalletIndex = selectedWalletIndex, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapFlingBehavior.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapFlingBehavior.kt new file mode 100644 index 0000000000..4ce45d17f4 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapFlingBehavior.kt @@ -0,0 +1,405 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.components + +import androidx.compose.animation.core.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.ui.MotionDurationScale +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.withContext +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.sign + +@ExperimentalFoundationApi +class TangemSnapFlingBehavior( + private val snapLayoutInfoProvider: SnapLayoutInfoProvider, + private val lowVelocityAnimationSpec: AnimationSpec, + private val highVelocityAnimationSpec: DecayAnimationSpec, + private val snapAnimationSpec: AnimationSpec, + private val density: Density, + private val shortSnapVelocityThreshold: Dp = MinFlingVelocityDp, +) : FlingBehavior { + + private val velocityThreshold = with(density) { shortSnapVelocityThreshold.toPx() } + private var motionScaleDuration = DefaultScrollMotionDurationScale + + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + return performFling(initialVelocity) {} + } + + /** + * Perform a snapping fling animation with given velocity and suspend until fling has + * finished. This will behave the same way as [performFling] except it will report on + * each remainingOffsetUpdate using the [onSettlingDistanceUpdated] lambda. + * + * @param initialVelocity velocity available for fling in the orientation specified in + * [androidx.compose.foundation.gestures.scrollable] that invoked this method. + * + * @param onSettlingDistanceUpdated a lambda that will be called anytime the + * distance to the settling offset is updated. The settling offset is the final offset where + * this fling will stop and may change depending on the snapping animation progression. + * + * @return remaining velocity after fling operation has ended + */ + private suspend fun ScrollScope.performFling( + initialVelocity: Float, + onSettlingDistanceUpdated: (Float) -> Unit, + ): Float { + val (remainingOffset, remainingState) = fling(initialVelocity, onSettlingDistanceUpdated) + + // No remaining offset means we've used everything, no need to propagate velocity. Otherwise + // we couldn't use everything (probably because we have hit the min/max bounds of the + // containing layout) we should propagate the offset. + return if (remainingOffset == 0f) NoVelocity else remainingState.velocity + } + + private suspend fun ScrollScope.fling( + initialVelocity: Float, + onRemainingScrollOffsetUpdate: (Float) -> Unit, + ): AnimationResult { + // If snapping from scroll (short snap) or fling (long snap) + val result = withContext(motionScaleDuration) { + if (abs(initialVelocity) <= abs(velocityThreshold)) { + shortSnap(initialVelocity, onRemainingScrollOffsetUpdate) + } else { + longSnap(initialVelocity, onRemainingScrollOffsetUpdate) + } + } + + onRemainingScrollOffsetUpdate(0f) // Animation finished or was cancelled + return result + } + + private suspend fun ScrollScope.shortSnap( + velocity: Float, + onRemainingScrollOffsetUpdate: (Float) -> Unit, + ): AnimationResult { + val closestOffset = with(snapLayoutInfoProvider) { + density.calculateSnappingOffset(0f) + } + + var remainingScrollOffset = closestOffset + + val animationState = AnimationState(NoDistance, velocity) + return animateSnap( + closestOffset, + closestOffset, + animationState, + snapAnimationSpec, + ) { delta -> + remainingScrollOffset -= delta + onRemainingScrollOffsetUpdate(remainingScrollOffset) + } + } + + private suspend fun ScrollScope.longSnap( + initialVelocity: Float, + onAnimationStep: (remainingScrollOffset: Float) -> Unit, + ): AnimationResult { + val initialOffset = + with(snapLayoutInfoProvider) { density.calculateApproachOffset(initialVelocity) }.let { + abs(it) * sign(initialVelocity) // ensure offset sign is correct + } + var remainingScrollOffset = initialOffset + + onAnimationStep(remainingScrollOffset) // First Scroll Offset + + val (remainingOffset, animationState) = runApproach( + initialOffset, + initialVelocity, + ) { delta -> + remainingScrollOffset -= delta + onAnimationStep(remainingScrollOffset) + } + + remainingScrollOffset = remainingOffset + + return animateSnap( + remainingOffset, + remainingOffset, + animationState.copy(value = 0f), + snapAnimationSpec, + ) { delta -> + remainingScrollOffset -= delta + onAnimationStep(remainingScrollOffset) + } + } + + private suspend fun ScrollScope.runApproach( + initialTargetOffset: Float, + initialVelocity: Float, + onAnimationStep: (delta: Float) -> Unit, + ): AnimationResult { + val animation = + if (isDecayApproachPossible(offset = initialTargetOffset, velocity = initialVelocity)) { + HighVelocityApproachAnimation(highVelocityAnimationSpec) + } else { + LowVelocityApproachAnimation( + lowVelocityAnimationSpec, + snapLayoutInfoProvider, + density, + ) + } + + return approach( + initialTargetOffset, + initialVelocity, + animation, + snapLayoutInfoProvider, + density, + onAnimationStep, + ) + } + + /** + * If we can approach the target and still have velocity left + */ + private fun isDecayApproachPossible(offset: Float, velocity: Float): Boolean { + val decayOffset = highVelocityAnimationSpec.calculateTargetValue(NoDistance, velocity) + val snapStepSize = with(snapLayoutInfoProvider) { density.calculateSnapStepSize() } + return decayOffset.absoluteValue >= offset.absoluteValue + snapStepSize + } + + override fun equals(other: Any?): Boolean { + return if (other is TangemSnapFlingBehavior) { + other.snapAnimationSpec == this.snapAnimationSpec && + other.highVelocityAnimationSpec == this.highVelocityAnimationSpec && + other.lowVelocityAnimationSpec == this.lowVelocityAnimationSpec && + other.snapLayoutInfoProvider == this.snapLayoutInfoProvider && + other.density == this.density && + other.shortSnapVelocityThreshold == this.shortSnapVelocityThreshold + } else { + false + } + } + + override fun hashCode(): Int = 0 + .let { 31 * it + snapAnimationSpec.hashCode() } + .let { 31 * it + highVelocityAnimationSpec.hashCode() } + .let { 31 * it + lowVelocityAnimationSpec.hashCode() } + .let { 31 * it + snapLayoutInfoProvider.hashCode() } + .let { 31 * it + density.hashCode() } + .let { 31 * it + shortSnapVelocityThreshold.hashCode() } +} + +@Suppress("LongParameterList") +@OptIn(ExperimentalFoundationApi::class) +private suspend fun ScrollScope.approach( + initialTargetOffset: Float, + initialVelocity: Float, + animation: ApproachAnimation, + snapLayoutInfoProvider: SnapLayoutInfoProvider, + density: Density, + onAnimationStep: (delta: Float) -> Unit, +): AnimationResult { + val (_, currentAnimationState) = animation.approachAnimation( + this, + initialTargetOffset, + initialVelocity, + onAnimationStep, + ) + + val remainingOffset = with(snapLayoutInfoProvider) { + density.calculateSnappingOffset(currentAnimationState.velocity) + } + + // will snap the remainder + return AnimationResult(remainingOffset, currentAnimationState) +} + +/** + * Runs a [AnimationSpec] to snap the list into [targetOffset]. Uses [cancelOffset] to stop this + * animation before it reaches the target. + * + * @param targetOffset The final target of this animation + * @param cancelOffset If we'd like to finish the animation earlier we use this value + * @param animationState The current animation state for continuation purposes + * @param snapAnimationSpec The [AnimationSpec] that will drive this animation + * @param onAnimationStep Called for each new scroll delta emitted by the animation cycle. + */ +@Suppress("MagicNumber") +private suspend fun ScrollScope.animateSnap( + targetOffset: Float, + cancelOffset: Float, + animationState: AnimationState, + snapAnimationSpec: AnimationSpec, + onAnimationStep: (delta: Float) -> Unit, +): AnimationResult { + var consumedUpToNow = 0f + val initialVelocity = animationState.velocity + animationState.animateTo( + targetOffset, + animationSpec = snapAnimationSpec, + sequentialAnimation = animationState.velocity != 0f, + ) { + val realValue = value.coerceToTarget(cancelOffset) + val delta = realValue - consumedUpToNow + val consumed = scrollBy(delta) + onAnimationStep(consumed) + // stop when unconsumed or when we reach the desired value + if (abs(delta - consumed) > 0.5f || realValue != value) { + cancelAnimation() + } + consumedUpToNow += consumed + } + + // Always course correct velocity so they don't become too large. + val finalVelocity = animationState.velocity.coerceToTarget(initialVelocity) + return AnimationResult( + targetOffset - consumedUpToNow, + animationState.copy(velocity = finalVelocity), + ) +} + +private class HighVelocityApproachAnimation( + private val decayAnimationSpec: DecayAnimationSpec, +) : ApproachAnimation { + override suspend fun approachAnimation( + scope: ScrollScope, + offset: Float, + velocity: Float, + onAnimationStep: (delta: Float) -> Unit, + ): AnimationResult { + val animationState = AnimationState(initialValue = 0f, initialVelocity = velocity) + return with(scope) { + animateDecay(offset, animationState, decayAnimationSpec, onAnimationStep) + } + } +} + +private class LowVelocityApproachAnimation @OptIn(ExperimentalFoundationApi::class) constructor( + private val lowVelocityAnimationSpec: AnimationSpec, + private val layoutInfoProvider: SnapLayoutInfoProvider, + private val density: Density, +) : ApproachAnimation { + @OptIn(ExperimentalFoundationApi::class) + override suspend fun approachAnimation( + scope: ScrollScope, + offset: Float, + velocity: Float, + onAnimationStep: (delta: Float) -> Unit, + ): AnimationResult { + val animationState = AnimationState(initialValue = 0f, initialVelocity = velocity) + val targetOffset = + (abs(offset) + with(layoutInfoProvider) { density.calculateSnapStepSize() }) * sign( + velocity, + ) + return with(scope) { + animateSnap( + targetOffset = targetOffset, + cancelOffset = offset, + animationState = animationState, + snapAnimationSpec = lowVelocityAnimationSpec, + onAnimationStep = onAnimationStep, + ) + } + } +} + +@Suppress("MagicNumber") +private suspend fun ScrollScope.animateDecay( + targetOffset: Float, + animationState: AnimationState, + decayAnimationSpec: DecayAnimationSpec, + onAnimationStep: (delta: Float) -> Unit, +): AnimationResult { + var previousValue = 0f + + fun AnimationScope.consumeDelta(delta: Float) { + val consumed = scrollBy(delta) + onAnimationStep(consumed) + if (abs(delta - consumed) > 0.5f) cancelAnimation() + } + + animationState.animateDecay( + animationSpec = decayAnimationSpec, + sequentialAnimation = animationState.velocity != 0f, + ) { + previousValue = if (abs(value) >= abs(targetOffset)) { + val finalValue = value.coerceToTarget(targetOffset) + val finalDelta = finalValue - previousValue + consumeDelta(finalDelta) + cancelAnimation() + finalValue + } else { + val delta = value - previousValue + consumeDelta(delta) + value + } + } + + return AnimationResult( + targetOffset - previousValue, + animationState, + ) +} + +private interface ApproachAnimation { + suspend fun approachAnimation( + scope: ScrollScope, + offset: T, + velocity: T, + onAnimationStep: (delta: T) -> Unit, + ): AnimationResult +} + +private fun Float.coerceToTarget(target: Float): Float { + if (target == 0f) return 0f + return if (target > 0) coerceAtMost(target) else coerceAtLeast(target) +} + +private class AnimationResult( + val remainingOffset: T, + val currentAnimationState: AnimationState, +) { + operator fun component1(): T = remainingOffset + operator fun component2(): AnimationState = currentAnimationState +} + +@Suppress("TopLevelPropertyNaming") +private const val DefaultScrollMotionDurationScaleFactor = 1f + +@Suppress("TopLevelPropertyNaming") +val DefaultScrollMotionDurationScale = object : MotionDurationScale { + override val scaleFactor: Float + get() = DefaultScrollMotionDurationScaleFactor +} + +@Suppress("TopLevelPropertyNaming") +internal val MinFlingVelocityDp = 400.dp + +@Suppress("TopLevelPropertyNaming") +internal const val NoDistance = 0f + +@Suppress("TopLevelPropertyNaming") +internal const val NoVelocity = 0f + +@ExperimentalFoundationApi +interface SnapLayoutInfoProvider { + /** + * The minimum offset that snapping will use to animate.(e.g. an item size) + */ + fun Density.calculateSnapStepSize(): Float + + /** + * Calculate the distance to navigate before settling into the next snapping bound. + * + * @param initialVelocity The current fling movement velocity. You can use this tho calculate a + * velocity based offset. + */ + fun Density.calculateApproachOffset(initialVelocity: Float): Float + + /** + * Given a target placement in a layout, the snapping offset is the next snapping position + * this layout can be placed in. If this is a short snapping, [currentVelocity] is guaranteed + * to be 0.If it is a long snapping, this method will be called + * after [calculateApproachOffset]. + * + * @param currentVelocity The current fling movement velocity. This may change throughout the + * fling animation. + */ + fun Density.calculateSnappingOffset(currentVelocity: Float): Float +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt new file mode 100644 index 0000000000..1d68cf0349 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/TangemSnapLayoutInfoProvider.kt @@ -0,0 +1,177 @@ +package com.tangem.feature.wallet.presentation.wallet.ui.components + +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.unit.Density +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.sign + +/** + * A [SnapLayoutInfoProvider] for LazyLists. + * + * @param lazyListState The [LazyListState] with information about the current state of the list + * @param positionInLayout The desired positioning of the snapped item within the main layout. + * This position should be considered with regard to the start edge of the item and the placement + * within the viewport. + * + * @return A [SnapLayoutInfoProvider] that can be used with [SnapFlingBehavior] + */ +@Suppress("FunctionNaming") +@ExperimentalFoundationApi +fun TangemSnapLayoutInfoProvider( + lazyListState: LazyListState, + positionInLayout: SnapPositionInLayout = SnapPositionInLayout.CenterToCenter, +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + + private val layoutInfo: LazyListLayoutInfo + get() = lazyListState.layoutInfo + + // Decayed page snapping is the default + override fun Density.calculateApproachOffset(initialVelocity: Float): Float { + val decayAnimationSpec: DecayAnimationSpec = splineBasedDecay(this) + val offset = + decayAnimationSpec.calculateTargetValue(NoDistance, initialVelocity).absoluteValue + val finalDecayOffset = (offset - calculateSnapStepSize()).coerceAtLeast(0f) + return if (finalDecayOffset == 0f) { + finalDecayOffset + } else { + finalDecayOffset * initialVelocity.sign + } + } + + override fun Density.calculateSnappingOffset(currentVelocity: Float): Float { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = + calculateDistanceToDesiredSnapPosition( + mainAxisViewPortSize = layoutInfo.singleAxisViewportSize, + beforeContentPadding = layoutInfo.beforeContentPadding, + afterContentPadding = layoutInfo.afterContentPadding, + itemSize = item.size, + itemOffset = item.offset, + itemIndex = item.index, + snapPositionInLayout = positionInLayout, + ) + + // Find item that is closest to the center + if (offset <= 0 && offset > lowerBoundOffset) { + lowerBoundOffset = offset + } + + // Find item that is closest to center, but after it + if (offset >= 0 && offset < upperBoundOffset) { + upperBoundOffset = offset + } + } + + return calculateFinalOffset(currentVelocity, lowerBoundOffset, upperBoundOffset) + } + + override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) { + if (visibleItemsInfo.isNotEmpty()) { + visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat() + } else { + 0f + } + } +} + +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +inline fun List.fastSumBy(selector: (T) -> Int): Int { + contract { callsInPlace(selector) } + var sum = 0 + fastForEach { element -> + sum += selector(element) + } + return sum +} + +internal fun calculateFinalOffset(velocity: Float, lowerBound: Float, upperBound: Float): Float { + fun Float.isValidDistance(): Boolean { + return this != Float.POSITIVE_INFINITY && this != Float.NEGATIVE_INFINITY + } + + val finalDistance = when (sign(velocity)) { + 0f -> { + if (abs(upperBound) <= abs(lowerBound)) { + upperBound + } else { + lowerBound + } + } + + 1f -> upperBound + -1f -> lowerBound + else -> NoDistance + } + + return if (finalDistance.isValidDistance()) { + finalDistance + } else { + NoDistance + } +} + +internal val LazyListLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width + +@Suppress("BanInlineOptIn") +@OptIn(ExperimentalContracts::class) +inline fun List.fastForEach(action: (T) -> Unit) { + contract { callsInPlace(action) } + for (index in indices) { + val item = get(index) + action(item) + } +} + +@Suppress("LongParameterList") +@OptIn(ExperimentalFoundationApi::class) +internal fun Density.calculateDistanceToDesiredSnapPosition( + mainAxisViewPortSize: Int, + beforeContentPadding: Int, + afterContentPadding: Int, + itemSize: Int, + itemOffset: Int, + itemIndex: Int, + snapPositionInLayout: SnapPositionInLayout, +): Float { + val containerSize = mainAxisViewPortSize - beforeContentPadding - afterContentPadding + + val desiredDistance = with(snapPositionInLayout) { + position(containerSize, itemSize, itemIndex) + }.toFloat() + + return itemOffset - desiredDistance +} + +@ExperimentalFoundationApi +fun interface SnapPositionInLayout { + /** + * Calculates an offset positioning between a container and an element within this container. + * The offset calculation is the necessary diff that should be applied to the item offset to + * align the item with a position within the container. As a base line, if we wanted to align + * the start of the container and the start of the item, we would return 0 in this function. + */ + fun Density.position(layoutSize: Int, itemSize: Int, itemIndex: Int): Int + + companion object { + /** + * Aligns the center of the item with the center of the containing layout. + */ + val CenterToCenter = + SnapPositionInLayout { layoutSize, itemSize, _ -> layoutSize / 2 - itemSize / 2 } + } +} \ 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 455cf810c4..c02a45e56e 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 @@ -4,8 +4,6 @@ import androidx.compose.animation.core.* import androidx.compose.animation.rememberSplineBasedDecay import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior -import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -31,7 +29,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.common.Wallet import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList -private const val SHORT_SNAP_ELEMENT_COUNT = 50 +private const val SHORT_SNAP_ELEMENT_COUNT = 25 /** * Wallets list component @@ -76,23 +74,20 @@ internal fun WalletsList( /** * Custom implementation of fling behaviour that overrides 'shortSnapVelocityThreshold'. - * Every user's drag action will similar to a short snap - * if drag offset is less than [SHORT_SNAP_ELEMENT_COUNT] * item width. * * @param lazyListState lazy list state - * @param itemWidth list item width * * @see rememberSnapFlingBehavior */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState, itemWidth: Dp): SnapFlingBehavior { - val snappingLayout = remember(lazyListState) { SnapLayoutInfoProvider(lazyListState) } +private fun rememberWalletsFlingBehaviour(lazyListState: LazyListState, itemWidth: Dp): TangemSnapFlingBehavior { + val snappingLayout = remember(lazyListState) { TangemSnapLayoutInfoProvider(lazyListState) } val density = LocalDensity.current val highVelocityApproachSpec: DecayAnimationSpec = rememberSplineBasedDecay() return remember(key1 = snappingLayout, key2 = highVelocityApproachSpec, key3 = density) { - SnapFlingBehavior( + TangemSnapFlingBehavior( snapLayoutInfoProvider = snappingLayout, lowVelocityAnimationSpec = tween(durationMillis = 1000, easing = LinearEasing), highVelocityAnimationSpec = highVelocityApproachSpec, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollector.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollector.kt index 939886db4a..63fbab40fd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollector.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/utils/ScrollOffsetCollector.kt @@ -1,6 +1,5 @@ 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 @@ -20,14 +19,14 @@ internal class ScrollOffsetCollector( selectedWalletIndex: Int, private val lazyListState: LazyListState, private val onWalletChange: (Int) -> Unit, -) : FlowCollector> { +) : FlowCollector> { - private val LazyListItemInfo.halfItemSize + private val LazyListItemData.halfItemSize get() = size.div(other = 2) private var currentIndex = selectedWalletIndex - override suspend fun emit(value: List) { + override suspend fun emit(value: List) { if (!lazyListState.isScrollInProgress || value.size <= 1) return val firstItem = value.firstOrNull() ?: return @@ -46,4 +45,10 @@ internal class ScrollOffsetCollector( onWalletChange(newIndex) } } -} \ No newline at end of file +} + +internal data class LazyListItemData( + val index: Int, + val size: Int, + val offset: Int, +) \ 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 6c4a46d2ee..62d6a03cfd 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 @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.viewmodels +import androidx.compose.runtime.MutableState import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -26,6 +27,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntents +import com.tangem.features.managetokens.navigation.ExpandableState import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -81,6 +83,12 @@ internal class WalletViewModel @Inject constructor( clickIntents.initialize(router, viewModelScope) } + fun setExpandableState(state: MutableState) { + stateHolder.update { + it.copy(manageTokensExpandableState = state) + } + } + fun subscribeToLifecycle(lifecycleOwner: LifecycleOwner) { lifecycleOwner.lifecycle.addObserver(screenLifecycleProvider) } @@ -170,7 +178,7 @@ internal class WalletViewModel @Inject constructor( } } - private fun updateWallets(action: WalletsUpdateActionResolver.Action) { + private suspend fun updateWallets(action: WalletsUpdateActionResolver.Action) { when (action) { is WalletsUpdateActionResolver.Action.InitializeWallets -> initializeWallets(action) is WalletsUpdateActionResolver.Action.ReinitializeWallets -> { @@ -195,7 +203,7 @@ internal class WalletViewModel @Inject constructor( } } - private fun initializeWallets(action: WalletsUpdateActionResolver.Action.InitializeWallets) { + private suspend fun initializeWallets(action: WalletsUpdateActionResolver.Action.InitializeWallets) { walletScreenContentLoader.load( userWallet = action.selectedWallet, clickIntents = clickIntents, @@ -211,100 +219,96 @@ internal class WalletViewModel @Inject constructor( ), ) - viewModelScope.launch(dispatchers.main) { - if (action.wallets.size > 1 && isWalletsScrollPreviewEnabled()) { - withContext(dispatchers.io) { - delay(timeMillis = 1_800) - } + 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 - }, - ), - ) - } + walletEventSender.send( + event = WalletEvent.DemonstrateWalletsScrollPreview( + direction = if (action.selectedWalletIndex == action.wallets.lastIndex) { + Direction.RIGHT + } else { + Direction.LEFT + }, + ), + ) } } private fun reinitializeWallet(action: WalletsUpdateActionResolver.Action.ReinitializeWallet) { - viewModelScope.launch(dispatchers.main) { - walletScreenContentLoader.cancel(action.prevWalletId) + walletScreenContentLoader.cancel(action.prevWalletId) - walletScreenContentLoader.load( - userWallet = action.selectedWallet, - clickIntents = clickIntents, - coroutineScope = viewModelScope, - ) + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) - stateHolder.update( - ReinitializeWalletTransformer(userWallet = action.selectedWallet, clickIntents = clickIntents), - ) - } + stateHolder.update( + ReinitializeWalletTransformer(userWallet = action.selectedWallet, clickIntents = clickIntents), + ) } - private fun addWallet(action: WalletsUpdateActionResolver.Action.AddWallet) { - viewModelScope.launch(dispatchers.main) { - stateHolder.update( - AddWalletTransformer( - userWallet = action.selectedWallet, - clickIntents = clickIntents, - ), - ) - - walletScreenContentLoader.load( + private suspend fun addWallet(action: WalletsUpdateActionResolver.Action.AddWallet) { + stateHolder.update( + AddWalletTransformer( userWallet = action.selectedWallet, clickIntents = clickIntents, - coroutineScope = viewModelScope, - ) + ), + ) + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) + + withContext(dispatchers.io) { delay(timeMillis = 700) } + + scrollToWallet(index = action.selectedWalletIndex) + } + + private suspend fun deleteWallet(action: WalletsUpdateActionResolver.Action.DeleteWallet) { + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) + + if (action.selectedWalletIndex != 0) { + /* + * If card is reset to factory settings, then Compose need some time to draw the WalletScreen. + * Otherwise, scroll isn't happened + */ withContext(dispatchers.io) { delay(timeMillis = 700) } scrollToWallet(index = action.selectedWalletIndex) + + withContext(dispatchers.io) { delay(timeMillis = 1000) } } + + stateHolder.update( + DeleteWalletTransformer( + selectedWalletIndex = action.selectedWalletIndex, + deletedWalletId = action.deletedWalletId, + ), + ) } - private fun deleteWallet(action: WalletsUpdateActionResolver.Action.DeleteWallet) { - viewModelScope.launch(dispatchers.main) { - walletScreenContentLoader.load( - userWallet = action.selectedWallet, + private suspend fun unlockWallet(action: WalletsUpdateActionResolver.Action.UnlockWallet) { + withContext(dispatchers.io) { delay(timeMillis = 700) } + + stateHolder.update( + transformer = UnlockWalletTransformer( + unlockedWallets = action.unlockedWallets, 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: WalletsUpdateActionResolver.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, - clickIntents = clickIntents, - coroutineScope = viewModelScope, - ) - } + walletScreenContentLoader.load( + userWallet = action.selectedWallet, + clickIntents = clickIntents, + coroutineScope = viewModelScope, + ) } private fun scrollToWallet(index: Int) { diff --git a/gradle.properties b/gradle.properties index 4131a81f9d..7218026460 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,3 +15,4 @@ org.gradle.daemon = true android.useAndroidX = true android.enableJetifier = true org.gradle.unsafe.configuration-cache = true +android.nonTransitiveRClass = false diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 08469f5c44..60e7423e4c 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -3,10 +3,10 @@ [versions] # region Classpath -androidGradlePlugin = "7.4.2" +androidGradlePlugin = "8.2.2" firebaseCrashlytics = "2.9.4" googleServices = "4.3.10" -kotlin = "1.8.21" +kotlin = "1.9.22" # endregion Classpath # region AndroidX @@ -24,17 +24,17 @@ androidx-datastore = "1.0.0" # endregion AndroidX # region Compose -compose-compiler = "1.4.7" -compose-runtime = "1.5.3" -compose-foundation = "1.5.3" -compose-material = "1.5.3" -compose-material3 = "1.1.2" +compose-compiler = "1.5.9" +compose-runtime = "1.6.1" +compose-foundation = "1.6.1" +compose-material = "1.6.1" +compose-material3 = "1.2.0" compose-constraint = "1.0.1" -compose-navigation = "2.7.4" +compose-navigation = "2.7.7" compose-accompanist = "0.30.1" compose-paging = "3.2.1" compose-reorderable = "0.9.6" -compose-lifecycle-runtime = "2.6.2" +compose-lifecycle-runtime = "2.7.0" # endregion Compose # region Other libraries @@ -50,7 +50,7 @@ googleMaterialComponent = "1.6.1" googlePlayCore = "1.10.3" googlePlayCoreKtx = "1.8.1" googlePlayServicesWallet = "19.1.0" -hilt = "2.44" +hilt = "2.46" hilt-navigation = "1.0.0" jodatime = "2.12.1" kotlin-immutable-collections = "0.3.5" @@ -85,9 +85,9 @@ web3j = "4.10.1" # endregion Other libraries # region Tangem -tangemBlockchainSdk = "release-app_5.7-509" +tangemBlockchainSdk = "develop-514" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "release-app_5.7-330" +tangemCardSdk = "develop-332" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ # endregion Tangem diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 135c32a8de..f5e4b2a14c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Mar 07 14:45:01 MSK 2023 -distributionBase = GRADLE_USER_HOME -distributionUrl = https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip -distributionPath = wrapper/dists -zipStorePath = wrapper/dists -zipStoreBase = GRADLE_USER_HOME +#Tue Feb 13 17:04:37 MSK 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index 1fbece85d5..e1341f668a 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -2,4 +2,8 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) id("configuration") +} + +android { + namespace = "com.tangem.lib.auth" } \ No newline at end of file diff --git a/libs/auth/src/main/AndroidManifest.xml b/libs/auth/src/main/AndroidManifest.xml deleted file mode 100644 index e314bdebe7..0000000000 --- a/libs/auth/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/libs/crypto/build.gradle.kts b/libs/crypto/build.gradle.kts index 917e9c310d..9a8ccaea31 100644 --- a/libs/crypto/build.gradle.kts +++ b/libs/crypto/build.gradle.kts @@ -6,6 +6,10 @@ plugins { id("configuration") } +android { + namespace = "com.tangem.lib.crypto" +} + dependencies { /** Coroutines */ diff --git a/libs/crypto/src/main/AndroidManifest.xml b/libs/crypto/src/main/AndroidManifest.xml deleted file mode 100644 index 1fd7238d41..0000000000 --- a/libs/crypto/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt index ee8eb94937..6b69d96fb8 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/TransactionManager.kt @@ -54,9 +54,6 @@ interface TransactionManager { derivationPath: String?, ): ProxyFees - @Throws(IllegalStateException::class) - fun getNativeTokenDecimals(networkId: String): Int - @Throws(IllegalStateException::class) suspend fun updateWalletManager(networkId: String, derivationPath: String?) diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/UserWalletManager.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/UserWalletManager.kt index 82b89c030b..c2e37da872 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/UserWalletManager.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/UserWalletManager.kt @@ -59,13 +59,6 @@ interface UserWalletManager { @Throws(IllegalStateException::class) suspend fun getNativeTokenBalance(networkId: String, derivationPath: String?): ProxyAmount? - /** - * @param networkId - * @return currency name - */ - @Throws(IllegalStateException::class) - fun getNetworkCurrency(networkId: String): String - @Throws(IllegalStateException::class) suspend fun getLastTransactionHash(networkId: String, derivationPath: String?): String? } \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyNetworkInfo.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyNetworkInfo.kt index c836504a95..dcbade5046 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyNetworkInfo.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/models/ProxyNetworkInfo.kt @@ -3,5 +3,4 @@ package com.tangem.lib.crypto.models data class ProxyNetworkInfo( val name: String, val blockchainId: String, - val blockchainCurrency: String, ) \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt index 05b2b465c2..f288d89a7d 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/AppExtensionConfigurations.kt @@ -35,6 +35,8 @@ private fun AppExtension.configureDefaultConfig(project: Project) { AppConfig.versionName } + buildFeatures.buildConfig = true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index 142649ae2a..c47360c0fc 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -20,7 +20,7 @@ internal fun BaseExtension.configureCompilerOptions() { internal fun BaseExtension.configureCompose(project: Project) { val useCompose = with(project.path) { contains(":ui") || - contains(Regex(pattern = ":onboarding\$")) || // TODO: divide on api/impl after migrating all onboarding to module + contains(":features:onboarding") || // TODO: divide on api/impl after migrating all onboarding to module contains(Regex(pattern = ":presentation\$")) || contains(Regex(pattern = ":app\$")) || // TODO: [REDACTED_JIRA] contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/LibraryExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/LibraryExtensionConfigurations.kt index bc18afa9b9..053151f9f1 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/LibraryExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/LibraryExtensionConfigurations.kt @@ -21,6 +21,7 @@ private fun LibraryExtension.configureDefaultConfig() { vectorDrawables { useSupportLibrary = true } + buildFeatures.buildConfig = true } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 19fcb80b60..fb31682f87 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -137,6 +137,7 @@ include(":domain:balance-hiding:models") include(":domain:transaction") include(":domain:analytics") include(":domain:visa") +include(":domain:onboarding") // endregion Domain modules // region Data modules @@ -154,4 +155,5 @@ include(":data:analytics") include(":data:transaction") include(":data:visa") include(":data:promo") +include(":data:onboarding") // endregion Data modules \ No newline at end of file