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/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..794eed1b33 100644 --- a/app/src/main/java/com/tangem/tap/TapApplication.kt +++ b/app/src/main/java/com/tangem/tap/TapApplication.kt @@ -30,11 +30,15 @@ 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.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 +159,21 @@ 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 // endregion Injected override fun onCreate() { @@ -183,8 +202,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 +256,11 @@ internal class TapApplication : Application(), ImageLoaderFactory { sendFeatureToggles = sendFeatureToggles, blockchainDataStorage = blockchainDataStorage, accountCreator = accountCreator, + userWalletsListManagerFeatureToggles = userWalletsListManagerFeatureToggles, + generalUserWalletsListManager = generalUserWalletsListManager, + wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, + saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, + cardRepository = cardRepository, ), ), ) 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/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/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..0d40b9bca5 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 @@ -268,7 +265,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 +275,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 +289,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 +358,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 +392,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 +419,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 +443,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 +470,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 +529,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 +559,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 +568,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/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 ec450b5f39..7794fc9ebc 100644 --- a/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt +++ b/app/src/main/java/com/tangem/tap/proxy/TransactionManagerImpl.kt @@ -17,6 +17,7 @@ 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 @@ -117,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? { 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..c2b7b52edd 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,15 @@ 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.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 +60,9 @@ 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, +) : 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/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/preferences/AppPreferencesStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/AppPreferencesStore.kt index 0c38cd669b..810c38230d 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,6 +55,15 @@ 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? { val type = Types.newParameterizedType(Map::class.java, String::class.java, V::class.java) 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..4ff06e36b2 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,12 @@ 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") } + + 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 +73,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..0180204de2 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,9 @@ { "name": "REDESIGNED_SEND_SCREEN_ENABLED", "version": "undefined" + }, + { + "name": "GENERAL_USER_WALLETS_LIST_MANAGER_ENABLED", + "version": "5.7.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/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_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/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/card/src/main/java/com/tangem/data/card/DefaultCardRepository.kt b/data/card/src/main/java/com/tangem/data/card/DefaultCardRepository.kt index 84c73a8dc7..0d868f3f10 100644 --- a/data/card/src/main/java/com/tangem/data/card/DefaultCardRepository.kt +++ b/data/card/src/main/java/com/tangem/data/card/DefaultCardRepository.kt @@ -1,9 +1,13 @@ package com.tangem.data.card +import androidx.datastore.preferences.core.MutablePreferences import com.tangem.datasource.local.card.UsedCardInfo import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectList +import com.tangem.datasource.local.preferences.utils.getObjectListSync +import com.tangem.datasource.local.preferences.utils.getSyncOrDefault +import com.tangem.datasource.local.preferences.utils.store import com.tangem.domain.card.repository.CardRepository import com.tangem.utils.extensions.addOrReplace import kotlinx.coroutines.flow.Flow @@ -21,23 +25,106 @@ internal class DefaultCardRepository( } override suspend fun setCardWasScanned(cardId: String) { - appPreferencesStore.editData { mutablePreferences -> - 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 updatedUsedCards = cardIds.map { cardId -> + usedCards.updateCard(cardId) { + it.copy(isActivationStarted = true, isActivationFinished = true) + } + } + + 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 } ?: UsedCardInfo(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 + } } } \ 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/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/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/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/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/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/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..961253bf87 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 @@ -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 val currenciesListJobHolder: JobHolder = JobHolder() - private var wallets: List by Delegates.notNull() + private val debouncer = Debouncer() - private var addedCurrenciesByWallet: MutableMap> = mutableMapOf() + 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 +122,53 @@ 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) } } } + private suspend fun subscribeToCurrencies(userWallets: List) { + wallets = CopyOnWriteArrayList(userWallets.filter { it.isMultiCurrency && !it.isLocked }) + + combine(wallets.map { getCurrenciesUseCase.invoke(it.walletId).distinctUntilChanged() }) { + 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/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/utils/SendOnNextScreenAnalyticSender.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/analytics/utils/SendOnNextScreenAnalyticSender.kt index e3f5666a1c..4fc8d719af 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 @@ -23,9 +23,6 @@ internal class SendOnNextScreenAnalyticSender( } analyticsEventHandler.send(SendAnalyticEvents.SelectedFee(selectedFee.name)) } - if (feeState.isSubtract) { - analyticsEventHandler.send(SendAnalyticEvents.SubtractFromAmount) - } } SendUiStateType.Amount -> { val isFiatSelected = state.amountState?.amountTextField?.isFiatValue ?: return 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..fbbc8953ab 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 @@ -3,6 +3,7 @@ package com.tangem.features.send.impl.presentation.state import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.event.triggeredEvent +import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState import com.tangem.features.send.impl.presentation.state.fee.FeeStateFactory @@ -43,6 +44,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 +89,29 @@ internal class SendEventStateFactory( } } + fun getFeeTooLowAlert(onConsume: () -> Unit): SendUiState { + val state = currentStateProvider() + val feeSelectorState = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return state + val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return state + val minimumValue = multipleFees.minimum.amount.value ?: return state + val customAmount = feeSelectorState.customValues.firstOrNull() ?: return state + val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals) + + val isFeeTooLow = feeSelectorState.selectedFee == FeeType.Custom && minimumValue > customValue + if (!isFeeTooLow) return state + + 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..12ea309a87 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,15 @@ 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.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.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 +21,47 @@ 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, ) { - 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 +169,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 +221,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 +254,40 @@ 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) + } + } + 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..34e2ddd3fe 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,13 +85,20 @@ 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, 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..bf7b83183b 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 @@ -14,58 +14,58 @@ 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) { + isEditingDisabled -> when (type) { SendUiStateType.Send -> { analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee)) showFee() } else -> popBackStack() } - else -> when (currentState.value) { + else -> when (type) { SendUiStateType.Amount -> { analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Address)) - showRecipient() + continueToSend(::showRecipient) } SendUiStateType.Fee -> { analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount)) - showAmount() + continueToSend(::showAmount) } SendUiStateType.Send -> { analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Fee)) - showFee() + continueToSend(::showFee) } - else -> popBackStack() + else -> continueToSend(::popBackStack) } } } fun onNextClick(): SendUiStateType { - val prevState = currentState.value - when (currentState.value) { + val prevState = currentState.value.type + when (currentState.value.type) { SendUiStateType.Recipient -> { analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Amount)) - showAmount() + continueToSend(::showAmount) } SendUiStateType.Amount -> { analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee)) - showFee() + continueToSend(::showFee) } SendUiStateType.Fee -> { analyticsEventsHandler.send(SendAnalyticEvents.NextButtonClicked(SendScreenSource.Fee)) @@ -83,7 +83,7 @@ internal class StateRouter( if (isEditingDisabled) { popBackStack() } else { - when (currentState.value) { + when (currentState.value.type) { SendUiStateType.Amount -> { analyticsEventsHandler.send(SendAnalyticEvents.BackButtonClicked(SendScreenSource.Amount)) showRecipient() @@ -97,23 +97,39 @@ internal class StateRouter( } } - 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/fee/FeeCalculation.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/state/fee/FeeCalculation.kt index 16d58d39a6..b9a182691f 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,14 @@ package com.tangem.features.send.impl.presentation.state.fee -import com.tangem.blockchain.common.transaction.Fee +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 } \ 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..7b31b34d3d 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,19 @@ 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.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.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 +24,19 @@ 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, ) { - 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 +49,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 +62,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 +77,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,6 +92,7 @@ internal class FeeNotificationFactory( ifRight = { it }, ) ?: return + val mergeFeeNetworkName = cryptoCurrencyStatus.shouldMergeFeeNetworkName() when (warning) { is CryptoCurrencyWarning.BalanceNotEnoughForFee -> { add( @@ -127,6 +102,7 @@ internal class FeeNotificationFactory( currencyName = cryptoCurrencyStatus.currency.name, feeName = warning.coinCurrency.name, feeSymbol = warning.coinCurrency.symbol, + mergeFeeNetworkName = mergeFeeNetworkName, onClick = { clickIntents.onTokenDetailsClick( userWalletId = userWalletId, @@ -145,6 +121,7 @@ internal class FeeNotificationFactory( feeName = warning.feeCurrencyName, feeSymbol = warning.feeCurrencySymbol, networkName = warning.networkName, + mergeFeeNetworkName = mergeFeeNetworkName, onClick = currency?.let { { clickIntents.onTokenDetailsClick( @@ -160,6 +137,11 @@ internal class FeeNotificationFactory( } } + // 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..d928d3c091 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,9 +52,8 @@ 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, @@ -68,43 +64,15 @@ internal class FeeStateFactory( ) 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 +86,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 +102,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 +128,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 +136,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 +147,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..2af0c26442 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 @@ -15,7 +15,6 @@ 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 +23,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: State) { 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 +54,13 @@ internal fun SendNavigationButtons(uiState: SendUiState) { } @Composable -private fun SendSecondaryNavigationButton(uiState: SendUiState) { - val currentState = uiState.currentState.collectAsState() +private fun SendSecondaryNavigationButton(uiState: SendUiState, currentState: State) { val isEditingDisabled = uiState.isEditingDisabled - val isCorrectScreen = currentState.value == SendUiStateType.Amount || currentState.value == SendUiStateType.Fee + val isFromConfirmation = currentState.value.isFromConfirmation + val isCorrectScreen = + currentState.value.type == SendUiStateType.Amount || currentState.value.type == SendUiStateType.Fee AnimatedVisibility( - visible = !isEditingDisabled && isCorrectScreen, + visible = !isEditingDisabled && isCorrectScreen && !isFromConfirmation, enter = expandHorizontally(expandFrom = Alignment.End), exit = shrinkHorizontally(shrinkTowards = Alignment.End), ) { @@ -77,8 +81,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: State, + modifier: Modifier = Modifier, +) { val isSuccess = uiState.sendState.isSuccess val isSending = uiState.sendState.isSending val txUrl = uiState.sendState.txUrl @@ -100,7 +107,7 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier modifier = modifier, ) { textId -> when { - currentState.value == SendUiStateType.Send && !isSuccess -> { + currentState.value.type == SendUiStateType.Send && !isSuccess -> { val hapticFeedback = rememberHapticFeedback(state = currentState, onAction = buttonClick) PrimaryButtonIconEnd( text = stringResource(textId), @@ -110,11 +117,11 @@ private fun SendPrimaryNavigationButton(uiState: SendUiState, modifier: Modifier showProgress = isSending, ) } - currentState.value == SendUiStateType.Send && isSuccess -> { + currentState.value.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 +184,19 @@ private fun PrimaryButtonsDone( private fun getButtonData( uiState: SendUiState, - currentState: State, + currentState: State, isSuccess: Boolean, ): Pair Unit> { - return when (currentState.value) { + return when (currentState.value.type) { SendUiStateType.None, SendUiStateType.Amount, SendUiStateType.Recipient, SendUiStateType.Fee, - -> R.string.common_next to uiState.clickIntents::onNextClick + -> if (currentState.value.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 +205,8 @@ private fun getButtonData( } } -private fun isButtonEnabled(currentState: State, uiState: SendUiState): Boolean { - return when (currentState.value) { +private fun isButtonEnabled(currentState: State, uiState: SendUiState): Boolean { + return when (currentState.value.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..2e0a451ec3 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 @@ -15,21 +15,21 @@ 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 +40,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 @@ -67,7 +67,7 @@ internal fun SendScreen(uiState: SendUiState) { modifier = Modifier .weight(1f), ) - SendNavigationButtons(uiState) + SendNavigationButtons(uiState, currentState) } SendEventEffect( @@ -79,16 +79,15 @@ internal fun SendScreen(uiState: SendUiState) { @Composable private fun SendScreenContent( uiState: SendUiState, - currentState: State, + currentState: State, modifier: Modifier = Modifier, ) { - val recipientList = uiState.recipientList.collectAsLazyPagingItems() AnimatedContent( targetState = currentState.value, label = "Send Scree Navigation", modifier = modifier, ) { state -> - when (state) { + when (state.type) { SendUiStateType.Amount -> SendAmountContent( amountState = uiState.amountState, isBalanceHiding = uiState.isBalanceHidden, @@ -97,7 +96,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/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..4c490fb1bc 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( @@ -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..9af06fb346 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") @@ -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..4cbe0159d8 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 @@ -44,18 +42,20 @@ 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.checkFeeCoverage 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 +70,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 +103,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 +124,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,6 +140,7 @@ internal class SendViewModel @Inject constructor( coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus }, currentStateProvider = Provider { uiState }, userWalletProvider = Provider { userWallet }, + stateRouterProvider = Provider { stateRouter }, clickIntents = this, getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, ) @@ -150,6 +150,7 @@ internal class SendViewModel @Inject constructor( coinCryptoCurrencyStatusProvider = Provider { coinCryptoCurrencyStatus }, currentStateProvider = Provider { uiState }, userWalletProvider = Provider { userWallet }, + stateRouterProvider = Provider { stateRouter }, currencyChecksRepository = currencyChecksRepository, clickIntents = this, ) @@ -170,6 +171,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 +181,35 @@ 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 +221,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 +259,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 +310,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 +330,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,35 +355,21 @@ 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) { - SendUiStateType.Fee -> loadFee() + when (it.type) { + SendUiStateType.Fee -> if (!it.isFromConfirmation) loadFee() SendUiStateType.Send -> sendIdleTimer = System.currentTimeMillis() else -> Unit } @@ -407,6 +401,23 @@ internal class SendViewModel @Inject constructor( override fun popBackStack() = stateRouter.popBackStack() override fun onBackClick() = stateRouter.onBackClick(uiState.sendState.isSuccess) override fun onNextClick() { + val currentState = stateRouter.currentState.value + val isCurrentFee = currentState.type == SendUiStateType.Fee + if (isCurrentFee) { + uiState = eventStateFactory.getFeeTooLowAlert( + onConsume = { uiState = eventStateFactory.onConsumeEventState() }, + ) + val isFeeCoverage = checkFeeCoverage(uiState, cryptoCurrencyStatus) + if (isAmountSubtractAvailable && isFeeCoverage) { + uiState = eventStateFactory.getFeeCoverageAlert( + onConsume = { uiState = eventStateFactory.onConsumeEventState() }, + ) + return + } else { + uiState = stateFactory.onSubtractSelect(false) + } + } + val prevScreen = stateRouter.onNextClick() sendOnNextScreenAnalyticSender.send(prevScreen, uiState) } @@ -482,11 +493,13 @@ internal class SendViewModel @Inject constructor( } 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 +511,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 +539,27 @@ 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() + } + + 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() uiState = callFeeUseCase()?.fold( - ifRight = { fees -> - feeStateFactory.onFeeOnLoadedState(fees, isAmountSubtractAvailable) - }, - ifLeft = { - feeStateFactory.onFeeOnErrorState() - }, + ifRight = feeStateFactory::onFeeOnLoadedState, + ifLeft = { feeStateFactory.onFeeOnErrorState() }, ) ?: feeStateFactory.onFeeOnErrorState() updateFeeNotifications() }.saveIn(feeJobHolder) @@ -573,48 +602,55 @@ internal class SendViewModel @Inject constructor( } override fun showAmount() { - stateRouter.showAmount() + uiState = stateFactory.onSubtractSelect(false) + stateRouter.showAmount(isFromConfirmation = true) analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Amount)) } override fun showRecipient() { - stateRouter.showRecipient() + uiState = stateFactory.onSubtractSelect(false) + stateRouter.showRecipient(isFromConfirmation = true) analyticsEventHandler.send(SendAnalyticEvents.ScreenReopened(SendScreenSource.Address)) } override fun showFee() { - stateRouter.showFee() + uiState = stateFactory.onSubtractSelect(false) + 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 } @@ -677,11 +713,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 +755,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/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/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/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 53e3148278..b71eded59b 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 @@ -693,6 +693,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 +1024,7 @@ internal class StateBuilder( fun showSelectProviderBottomSheet( uiState: SwapStateHolder, selectedProviderId: String, + bestRatedProviderId: String, pricesLowerBest: Map, providersStates: Map, unavailableProviders: List, @@ -1005,7 +1032,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 +1073,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 +1120,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 +1180,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, @@ -1211,7 +1241,7 @@ internal class StateBuilder( 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 +1286,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 +1317,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 +1353,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/viewmodels/SwapViewModel.kt b/features/swap/presentation/src/main/java/com/tangem/feature/swap/viewmodels/SwapViewModel.kt index d194494046..75561ff004 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 @@ -13,11 +14,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 @@ -46,7 +49,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 @@ -62,6 +64,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 { @@ -104,6 +107,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 @@ -173,6 +179,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) @@ -301,7 +324,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, @@ -617,7 +640,6 @@ internal class SwapViewModel @Inject constructor( unavailable = unavailable, afterSearch = true, ), - ) } else { tokenDataState.copy( @@ -647,15 +669,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, @@ -673,6 +715,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 @@ -835,13 +907,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) } }, @@ -950,17 +1023,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/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/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..803458c2fb 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 @@ -64,6 +71,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 +86,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 +109,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 +217,7 @@ private fun WalletContent( snackbarHostState = snackbarHostState, bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider, bottomSheetContent = bottomSheetContent, + alertConfig = alertConfig, ) { scaffoldContent() } @@ -222,7 +233,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 +241,46 @@ 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, + 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 +304,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 +316,153 @@ 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) +@Composable +private fun BottomSheetStateEffects( + bottomSheetState: SheetState, + 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() + } + } + } +} + +/** + * 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/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 e8e2ee6152..4c493dcd5b 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-504" +tangemBlockchainSdk = "develop-505" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "release-app_5.7-329" +tangemCardSdk = "develop-324" #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/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